diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ac23a7c..06840d059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ## Unreleased ### Fixes +- Fixed `arcade.math.rand_vec_spread_deg()` returning vectors in random directions. It passed the angle in degrees to a function that expects radians, so for example the spread emitters in the `particle_systems` example sprayed everywhere ([#2648](https://github.com/pythonarcade/arcade/issues/2648)). +- Fixed reading 2 GB or more from an OpenGL texture, texture array, framebuffer or buffer on Windows, which failed because `ctypes.string_at` takes its size as a 32-bit C long there ([#2836](https://github.com/pythonarcade/arcade/issues/2836)). - Fixed a player in `PhysicsEnginePlatformer` sliding along when standing across two moving platforms. It moved by both platforms' speeds added together, so it slid until it stood on only one. It now moves with the platform it stands on the most, and isn't moved by a platform it only touches the edge of while standing mostly on something still ([#2782](https://github.com/pythonarcade/arcade/issues/2782)). - Fixed the PyInstaller hook bundling Arcade's version file at `arcade/VERSION/VERSION`. Frozen apps printed an error on startup and reported `arcade.VERSION` as `0.0.0`, and builds using `collect_all("arcade")` failed because the hook and `collect_all` put a folder and a file at the same path. The version file is now `arcade/_VERSION`, so it can't be confused with the `arcade.version` module ([#2837](https://github.com/pythonarcade/arcade/issues/2837)). - Fixed drawing into a texture atlas losing new drawing when the atlas grows during `render_into()`. Nested rendering and exception exits now restore the previous camera and render region. diff --git a/arcade/gl/backends/opengl/buffer.py b/arcade/gl/backends/opengl/buffer.py index 176fd9bb2..3e230689c 100644 --- a/arcade/gl/backends/opengl/buffer.py +++ b/arcade/gl/backends/opengl/buffer.py @@ -1,7 +1,7 @@ from __future__ import annotations import weakref -from ctypes import byref, string_at +from ctypes import POINTER, byref, c_ubyte, cast from typing import TYPE_CHECKING from pyglet.graphics import core @@ -158,7 +158,9 @@ def read(self, size: int = -1, offset: int = 0) -> bytes: gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._glo) ptr = gl.glMapBufferRange(gl.GL_ARRAY_BUFFER, offset, size, gl.GL_MAP_READ_BIT) - data = string_at(ptr, size=size) + # Copy through a ctypes array rather than ctypes.string_at, which can't + # read 2 GB or more on Windows: its size is a C long + data = bytes(cast(ptr, POINTER(c_ubyte * size)).contents) gl.glUnmapBuffer(gl.GL_ARRAY_BUFFER) return data diff --git a/arcade/gl/backends/opengl/framebuffer.py b/arcade/gl/backends/opengl/framebuffer.py index 38fae0dd5..d027a384c 100644 --- a/arcade/gl/backends/opengl/framebuffer.py +++ b/arcade/gl/backends/opengl/framebuffer.py @@ -1,7 +1,7 @@ from __future__ import annotations import weakref -from ctypes import Array, c_int, c_uint, string_at +from ctypes import Array, c_int, c_uint from typing import TYPE_CHECKING from pyglet.graphics import core @@ -296,7 +296,9 @@ def read(self, *, viewport=None, components=3, attachment=0, dtype="f1") -> byte if not self.is_default: gl.glReadBuffer(gl.GL_COLOR_ATTACHMENT0) # Reset to default - return string_at(data, len(data)) + # bytes() rather than ctypes.string_at, which can't read 2 GB or more on + # Windows: its size is a C long + return bytes(data) def delete(self): """ diff --git a/arcade/gl/backends/opengl/texture.py b/arcade/gl/backends/opengl/texture.py index e01a7d030..4f8a8dc19 100644 --- a/arcade/gl/backends/opengl/texture.py +++ b/arcade/gl/backends/opengl/texture.py @@ -1,7 +1,7 @@ from __future__ import annotations import weakref -from ctypes import byref, string_at +from ctypes import byref from typing import TYPE_CHECKING from pyglet.graphics import core @@ -517,7 +517,9 @@ def read(self, level: int = 0, alignment: int = 1) -> bytes: gl.GLubyte * (self.width * self.height * self._component_size * self._components) )() gl.glGetTexImage(gl.GL_TEXTURE_2D, level, self._format, self._type, buffer) - return string_at(buffer, len(buffer)) + # bytes() rather than ctypes.string_at, which can't read 2 GB or more on + # Windows: its size is a C long + return bytes(buffer) elif self._ctx.gl_api == "opengles": fbo = self._ctx.framebuffer(color_attachments=[self]) return fbo.read(components=self._components, dtype=self._dtype) diff --git a/arcade/gl/backends/opengl/texture_array.py b/arcade/gl/backends/opengl/texture_array.py index 84349e205..e3ba07efe 100644 --- a/arcade/gl/backends/opengl/texture_array.py +++ b/arcade/gl/backends/opengl/texture_array.py @@ -1,7 +1,7 @@ from __future__ import annotations import weakref -from ctypes import byref, string_at +from ctypes import byref from typing import TYPE_CHECKING from pyglet.graphics import core @@ -468,7 +468,9 @@ def read(self, level: int = 0, alignment: int = 1) -> bytes: * (self.width * self.height * self.layers * self._component_size * self._components) )() gl.glGetTexImage(self._target, level, self._format, self._type, buffer) - return string_at(buffer, len(buffer)) + # bytes() rather than ctypes.string_at, which can't read 2 GB or more on + # Windows: its size is a C long + return bytes(buffer) elif self._ctx.gl_api == "opengles": # FIXME: Check if we can attach a layer to the framebuffer. See Texture2D.read() raise ValueError("Reading texture array data not supported in GLES yet") diff --git a/arcade/math.py b/arcade/math.py index 9924ac92c..a2179f407 100644 --- a/arcade/math.py +++ b/arcade/math.py @@ -285,12 +285,12 @@ def rand_vec_spread_deg(angle: float, half_angle_spread: float, length: float) - Returns a random vector, within a spread of the given angle. Args: - angle (float): The angle to spread from - half_angle_spread (float): The half angle spread + angle (float): The angle to spread from, in degrees + half_angle_spread (float): The half angle spread, in degrees length (float): The length of the vector """ a = rand_angle_spread_deg(angle, half_angle_spread) - vel = Vec2.from_polar(a, length) + vel = Vec2.from_polar(math.radians(a), length) return vel.x, vel.y diff --git a/tests/conftest.py b/tests/conftest.py index e28965a69..235b36acf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -433,30 +433,17 @@ def get_image(self) -> PIL.Image.Image: region = LBWH(0, 0, 1280, 720) return self.read_region_image(region) - def read_pixel(self, x, y, components=3) -> tuple[int, int, int, int] | tuple[int, int, int]: - """Read a single RGBA pixel from the offscreen buffer""" - data = self.fbo.read(components=4, viewport=(x, y, 1, 1)) - return ( - int.from_bytes(data[0:4], "little"), - int.from_bytes(data[4:8], "little"), - int.from_bytes(data[8:12], "little"), - int.from_bytes(data[12:16], "little"), - ) + def read_pixel(self, x, y, components=3) -> tuple[int, ...]: + """Read a single RGB or RGBA pixel from the offscreen buffer""" + # One byte per channel + return tuple(self.fbo.read(components=components, viewport=(x, y, 1, 1))) def read_region(self, rect: Rect) -> list[tuple[int, int, int, int]]: - """Read a region of RGBA pixels from the offscreen buffer""" + """Read a region of RGBA pixels from the offscreen buffer, row by row from the bottom""" data = self.fbo.read( components=4, viewport=(rect.left, rect.bottom, rect.width, rect.height) ) - return [ - ( - int.from_bytes(data[i : i + 4], "little"), - int.from_bytes(data[i + 4 : i + 8], "little"), - int.from_bytes(data[i + 8 : i + 12], "little"), - int.from_bytes(data[i + 12 : i + 16], "little"), - ) - for i in range(0, len(data), 16) - ] + return [(data[i], data[i + 1], data[i + 2], data[i + 3]) for i in range(0, len(data), 4)] def read_region_bytes(self, rect: Rect, components=3) -> bytes: """Read a region of RGBA pixels from the offscreen buffer as bytes""" diff --git a/tests/unit/test_math.py b/tests/unit/test_math.py index a32cedcac..224aaa144 100644 --- a/tests/unit/test_math.py +++ b/tests/unit/test_math.py @@ -7,6 +7,7 @@ import math +import pytest from pytest import approx from arcade.math import ( @@ -94,9 +95,14 @@ def test_rand_angle_spread_deg(): rand_angle_spread_deg(45.0, 5.0) -def test_rand_vec_spread_deg(): - """Smoke test""" - rand_vec_spread_deg(-45.0, 5.0, 3.3) +@pytest.mark.parametrize("angle", [-45.0, 0.0, 90.0, 200.0]) +def test_rand_vec_spread_deg(angle): + # The vector points within the spread of the angle, which is in degrees + for _ in range(200): + x, y = rand_vec_spread_deg(angle, 5.0, 3.3) + assert math.hypot(x, y) == approx(3.3) + difference = (math.degrees(math.atan2(y, x)) - angle + 180) % 360 - 180 + assert abs(difference) <= 5.0 + 1e-9 def test_rand_vec_magnitude(): diff --git a/tests/unit/test_offscreen_fixture.py b/tests/unit/test_offscreen_fixture.py new file mode 100644 index 000000000..f6a67e823 --- /dev/null +++ b/tests/unit/test_offscreen_fixture.py @@ -0,0 +1,21 @@ +"""The offscreen fixture's pixel readers return one value per channel.""" + +from arcade import LBWH + + +def test_read_pixel(offscreen): + offscreen.fbo.clear(color=(10, 20, 30, 40)) + assert offscreen.read_pixel(5, 6) == (10, 20, 30) + assert offscreen.read_pixel(5, 6, components=4) == (10, 20, 30, 40) + + +def test_read_region(offscreen): + offscreen.fbo.clear(color=(10, 20, 30, 40)) + offscreen.fbo.clear(color=(200, 100, 50, 255), viewport=(1, 0, 1, 1)) + # Bottom row first, left to right + assert offscreen.read_region(LBWH(0, 0, 2, 2)) == [ + (10, 20, 30, 40), + (200, 100, 50, 255), + (10, 20, 30, 40), + (10, 20, 30, 40), + ]