Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,5 @@ temp/

webplayground/**/*.whl
webplayground/**/*.zip
.claude/skills/*
.specify/*
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
- Fixed issues with update/draw rate handling that changes with Pyglet 3, rates are now handled properly between desktop and browser. See [#2845](https://github.com/pythonarcade/arcade/pull/2845)
- Fixed caret behavior not responding appropriately when activating an input field. See [#2850](https://github.com/pythonarcade/arcade/pull/2850)
- GUI: Fixed `UILabel.update_font` always reporting a font change when the requested font was a fallback tuple (e.g. `UIFlatButton`'s default styles), which caused a full UI re-render every frame. This made widget-heavy UIs, such as the `exp_scroll_area` example, run at a few FPS.

### Breaking Changes
- Updated pyglet to 3.0.dev6 (from 3.0.dev3). Between `dev4` and `dev5`, pyglet removed `window._matrices` and moved window view/projection/viewport onto its own `default_camera`, backed by a per-frame ring-buffer UBO. Arcade now fully owns its own window matrix UBO, independent of pyglet's `default_camera`/ring buffer, so rendering behavior for cameras, sprites, and shapes is unchanged. Advanced users who mix raw pyglet camera/window code with Arcade should note: `window._matrices` no longer exists, and `window.default_camera` is now pyglet's own concept (a `Camera2D`), distinct from `arcade.Window.default_camera` (Arcade's own `DefaultProjector`) — both exist simultaneously and serve different roles.

## 4.0.0.dev4

### New Features
Expand Down
4 changes: 2 additions & 2 deletions arcade/camera/camera_2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,8 @@ def use(self) -> None:

self._window.ctx.viewport = self._viewport.lbwh_int
self._window.ctx.scissor = None if not self.scissor else self.scissor.lbwh_int
self._window.projection = _projection
self._window.view = _view
self._window.ctx.projection_matrix = _projection
self._window.ctx.view_matrix = _view

@contextmanager
def activate(self) -> Generator[Self, None, None]:
Expand Down
67 changes: 67 additions & 0 deletions arcade/camera/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import TYPE_CHECKING

from pyglet.math import Mat4, Vec2, Vec3
from pyglet.window.camera import CameraScissor
from typing_extensions import Self

from arcade.camera.data_types import DEFAULT_FAR, DEFAULT_NEAR_ORTHO
Expand Down Expand Up @@ -147,6 +148,72 @@ def activate(self) -> Generator[Self, None, None]:
self._ctx.view_matrix = previous_view
self._ctx.current_camera = previous_projector

# --- pyglet dev6 batch-draw compatibility shim ---
#
# pyglet's own Batch.draw() (used e.g. by pyglet.text.Label.draw(), which
# arcade.Text delegates to) falls back to `window.default_camera` when no
# explicit camera is given. Since Arcade overrides `default_camera` to
# return this class instead of pyglet's own Camera2D, pyglet's batch
# pipeline needs DefaultProjector to satisfy its internal
# CameraScopeProtocol (`.view.scissor`, `.begin()`,
# `.get_group_scissor_area()`) or it raises AttributeError.
@property
def view(self) -> Self:
return self

def begin(self, *, draw_context, commit: bool = True) -> None:
pass

def get_group_scissor_area(self) -> CameraScissor:
# MUST NOT return None here. pyglet's GL renderer treats a scissor
# of None as "disable scissor testing entirely"
# (pyglet/graphics/api/gl/renderer.py: `set_scissor(None)` calls
# `glDisable(GL_SCISSOR_TEST)`) — but Arcade enables scissor testing
# once at context creation and never revisits it (it's not one of
# the flags `Context.enable`/`disable` manage), relying on the
# scissor *rectangle* alone to control the visible area. Returning
# None here let any pyglet-native draw with no camera set (e.g. any
# `arcade.Text`/`draw_text()` call, since those go through
# `pyglet.text.Label.draw()`) permanently disable scissor testing
# for the rest of the process — breaking any later viewport-scoped
# clear (`Framebuffer.clear(viewport=...)`) anywhere in the
# session. Returning a real scissor rectangle matching the current
# viewport keeps scissor testing enabled without restricting
# anything beyond what's already visible.
return CameraScissor(*(self._scissor or self.get_current_viewport()))

# pyglet's base Window.projection/.view properties (dev6+) delegate to
# `self.default_camera.projection` / `.view_matrix` respectively
# (`pyglet/window/__init__.py`). Any code that reads/writes
# `window.projection`/`window.view` — pyglet's own internals, test
# infrastructure, or downstream user code — routes through here once
# Arcade's `default_camera` override is in the MRO, so these need to
# exist and behave sensibly rather than raise AttributeError.
@property
def projection(self) -> Mat4:
if self._matrix is None:
self._matrix = Mat4.orthogonal_projection(
0, self.width, 0, self.height, DEFAULT_NEAR_ORTHO, DEFAULT_FAR
)
return self._matrix

@projection.setter
def projection(self, value: Mat4) -> None:
self._matrix = value
if self._ctx.current_camera is self:
self._ctx.projection_matrix = value

@property
def view_matrix(self) -> Mat4:
# DefaultProjector always uses an identity view (see .use()) — this
# mirrors that rather than tracking a separate, never-applied state.
return Mat4()

@view_matrix.setter
def view_matrix(self, value: Mat4) -> None:
if self._ctx.current_camera is self:
self._ctx.view_matrix = value

def project(self, world_coordinate: Point) -> Vec2:
"""
Take a Vec2 or Vec3 of coordinates and return the related screen coordinate
Expand Down
4 changes: 2 additions & 2 deletions arcade/camera/orthographic.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ def use(self) -> None:

self._window.ctx.viewport = self.viewport.lbwh_int
self._window.ctx.scissor = None if not self.scissor else self.scissor.lbwh_int
self._window.projection = _projection
self._window.view = _view
self._window.ctx.projection_matrix = _projection
self._window.ctx.view_matrix = _view

@contextmanager
def activate(self) -> Generator[Self, None, None]:
Expand Down
4 changes: 2 additions & 2 deletions arcade/camera/perspective.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ def use(self) -> None:

self._window.ctx.viewport = self.viewport.lbwh_int
self._window.ctx.scissor = None if not self.scissor else self.scissor.lbwh_int
self._window.projection = _projection
self._window.view = _view
self._window.ctx.projection_matrix = _projection
self._window.ctx.view_matrix = _view

def project(self, world_coordinate: Point) -> Vec2:
"""Convert world coordinates to pixel screen coordinates.
Expand Down
45 changes: 35 additions & 10 deletions arcade/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,20 @@ def __init__(
gc_mode: str = "context_gc",
gl_api: str = "gl",
) -> None:
# Set up a default orthogonal projection for sprites and shapes
# Mypy can't figure out the dynamic creation of the matrices in Pyglet
# They are created based on the active backend.
self._window_block = window._matrices.ubo # type: ignore
# Arcade fully owns this UBO, independent of pyglet's own
# default_camera-managed ring buffer, so its size stays bounded
# regardless of pyglet's per-frame resource lifecycle (spec FR-005).
# usage="stream" matches the actual write pattern (rewritten on every
# camera activation, read briefly after) rather than the default
# "static" (set once, never touched again) — see pyglet's own
# ring-buffer rationale for why a stale usage hint on a
# frequently-rewritten buffer risks GPU stalls.
self._window_block: Buffer = self.buffer(reserve=128, usage="stream")
self._projection_matrix: Mat4 = Mat4.orthogonal_projection(
0, window.width, 0, window.height, -100, 100
)
self._view_matrix: Mat4 = Mat4()
self._write_window_block()
self.bind_window_block()

self.blend_func = self.BLEND_DEFAULT
Expand Down Expand Up @@ -351,6 +361,15 @@ def bind_window_block(self) -> None:
"The currently selected GL backend does not implement ArcadeContext.bind_window_block"
)

def _write_window_block(self) -> None:
"""
Write the current projection/view matrices into Arcade's own
window-block UBO, matching the ``WindowBlock { mat4 projection;
mat4 view; }`` layout Arcade's shaders declare.
"""
self._window_block.write(array("f", self._projection_matrix), offset=0)
self._window_block.write(array("f", self._view_matrix), offset=64)

@property
def default_atlas(self) -> TextureAtlasBase:
"""
Expand Down Expand Up @@ -414,16 +433,19 @@ def projection_matrix(self) -> Mat4:
This 4x4 float32 matrix is usually calculated by a cameras but
can be modified directly if you know what you are doing.

This property simply gets and sets pyglet's projection matrix.
This property gets and sets Arcade's own window-block UBO
directly. It is independent of pyglet's own window matrix
storage (spec FR-002/FR-005).
"""
return self.window.projection
return self._projection_matrix

@projection_matrix.setter
def projection_matrix(self, value: Mat4):
if not isinstance(value, Mat4):
raise ValueError("projection_matrix must be a Mat4 object")

self.window.projection = value
self._projection_matrix = value
self._window_block.write(array("f", value), offset=0)

@property
def view_matrix(self) -> Mat4:
Expand All @@ -433,16 +455,19 @@ def view_matrix(self) -> Mat4:
This 4x4 float32 matrix is usually calculated by a cameras but
can be modified directly if you know what you are doing.

This property simply gets and sets pyglet's view matrix.
This property gets and sets Arcade's own window-block UBO
directly. It is independent of pyglet's own window matrix
storage (spec FR-002/FR-005).
"""
return self.window.view
return self._view_matrix

@view_matrix.setter
def view_matrix(self, value: Mat4):
if not isinstance(value, Mat4):
raise ValueError("view_matrix must be a Mat4 object")

self.window.view = value
self._view_matrix = value
self._window_block.write(array("f", value), offset=64)

def load_program(
self,
Expand Down
11 changes: 4 additions & 7 deletions arcade/gl/backends/opengl/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,13 +434,10 @@ def __init__(self, *args, **kwargs):
ArcadeContext.__init__(self, *args, **kwargs)

def bind_window_block(self):
gl.glBindBufferRange(
gl.GL_UNIFORM_BUFFER,
0,
self._window_block.buffer.id,
0, # type: ignore
128, # 32 x 32bit floats (two mat4) # type: ignore
)
# self._window_block is an arcade.gl.Buffer that Arcade owns directly
# (see ArcadeContext.__init__) — not sourced from pyglet's own
# default_camera-managed ring buffer.
self._window_block.bind_to_uniform_block(binding=0, offset=0, size=128)


class OpenGLInfo(Info):
Expand Down
11 changes: 4 additions & 7 deletions arcade/gl/backends/webgl/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,13 +382,10 @@ def __init__(self, *args, **kwargs):
ArcadeContext.__init__(self, *args, **kwargs)

def bind_window_block(self):
self._gl.bindBufferRange(
enums.UNIFORM_BUFFER,
0,
self._window_block.buffer.id,
0,
128,
)
# self._window_block is an arcade.gl.Buffer that Arcade owns directly
# (see ArcadeContext.__init__) — not sourced from pyglet's own
# default_camera-managed ring buffer.
self._window_block.bind_to_uniform_block(binding=0, offset=0, size=128)


class WebGLInfo(Info):
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ classifiers = [
dependencies = [
"pillow>=11.3.0",
"pytiled-parser~=2.2.9",
"pyglet==3.0.dev3",
"pyglet==3.0.dev6",
]
dynamic = ["version"]

Expand Down
66 changes: 66 additions & 0 deletions specs/001-pyglet-3-dev6-migration/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Specification Quality Checklist: pyglet 3.0.dev6 Migration

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-07-16
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

- This spec necessarily references specific pyglet APIs (e.g., the matrix UBO,
`default_camera`) because the feature *is* a dependency migration whose whole
purpose is reconciling with those specific external API changes. These are
external constraints/entities being migrated to, not Arcade implementation
choices, so they are treated as domain facts rather than implementation detail
leakage. Success criteria remain outcome-focused (tests pass, no crash, bounded
memory, matching rendered output).
- The approach to bounding UBO growth was resolved during clarification: Arcade
fully owns its own matrix UBO independent of pyglet's ring buffer (see
Clarifications session 2026-07-16). Detailed implementation design remains for
the planning phase.
- The spec originally assumed a WSL dev environment with limited graphics
capability, deferring the authoritative test run to a separate GPU-capable
environment. This was corrected (Clarifications session 2026-07-16,
environment correction): development is on native Windows with full
OpenGL/GPU capability, so the full test suite, stress/multi-window runs, and
image-comparison acceptance runs are executed and gated locally on Windows.
- Clarified that local Windows verification is additive to, not a replacement
for, the project's existing Linux+xvfb CI (`.github/workflows/test.yml`),
which continues to run unchanged on every push/PR (Clarifications session
2026-07-16, environment correction).
- `/speckit-analyze` (run after `/speckit-tasks`) found FR-002 directly
contradicted FR-005 (FR-002 said "read UBO from pyglet's location," FR-005
said "own an independent UBO" — only FR-005's design was ever implemented
in plan.md/tasks.md). Also found SC-003/FR-011 referenced a nonexistent
"existing" image-comparison tolerance. Both were corrected editorially
(Editorial correction 2026-07-16, post-/speckit-analyze): FR-002 and the
"Matrix UBO" Key Entity bullet now match the FR-005 design, and SC-003/
FR-011 now state a concrete numeric tolerance instead of an implied
pre-existing one. No new decisions were made — these corrections propagate
decisions already resolved during clarification/planning back into spec.md.
- Items marked incomplete require spec updates before `/speckit-clarify` or
`/speckit-plan`. All items currently pass.
Loading
Loading