diff --git a/data_only_viz/renderer.py b/data_only_viz/renderer.py index 3ace31d..58d72fb 100644 --- a/data_only_viz/renderer.py +++ b/data_only_viz/renderer.py @@ -243,7 +243,9 @@ class MetalRenderer(NSObject): def _init_mesh_cpu_buffer(self) -> None: if getattr(self, "_mesh_cpu_buf", None) is None: - self._mesh_cpu_buf = np.zeros(MESH_MAX_VERTS * 5, dtype=np.float32) + self._mesh_cpu_buf = np.zeros( + MESH_MAX_VERTS * MESH_VERT_FLOATS, dtype=np.float32, + ) # ---- Uniforms helpers ------------------------------------------ def _update_uniforms(self) -> int: @@ -401,7 +403,7 @@ class MetalRenderer(NSObject): ou triangle invalide (confiance basse).""" nonlocal n_verts tris = n_verts // 3 - if tris >= MESH_MAX_TRIS: + if tris >= MESH_MAX_TRIS or n_verts + 3 > MESH_MAX_VERTS: return False if i >= len(kp_list) or j >= len(kp_list) or k >= len(kp_list): return True # skip mais continue @@ -464,7 +466,8 @@ class MetalRenderer(NSObject): if n_verts == 0: return 0 - data = self._mesh_cpu_buf[: n_verts * 5].tobytes() + # Slice is exact — stale floats beyond n_verts*MESH_VERT_FLOATS never reach the GPU. + data = self._mesh_cpu_buf[: n_verts * MESH_VERT_FLOATS].tobytes() mv = self._mesh_buf.contents().as_buffer(len(data)) mv[:] = data return n_verts // 3 diff --git a/data_only_viz/tests/test_renderer_allocations.py b/data_only_viz/tests/test_renderer_allocations.py index 26bfb01..0c639d6 100644 --- a/data_only_viz/tests/test_renderer_allocations.py +++ b/data_only_viz/tests/test_renderer_allocations.py @@ -1,10 +1,15 @@ # data_only_viz/tests/test_renderer_allocations.py """Renderer must reuse a preallocated CPU staging buffer for the skeleton.""" +import time +import types +import unittest.mock as mock + import numpy as np +import pytest from data_only_viz.renderer import MetalRenderer, SKEL_MAX_SEGS, MESH_MAX_VERTS -from data_only_viz.state import State +from data_only_viz.state import PoseKp, State def test_skeleton_cpu_buffer_is_preallocated_and_reused(): @@ -38,3 +43,49 @@ def test_mesh_cpu_buffer_is_preallocated_and_reused(): assert buf.size == MESH_MAX_VERTS * 5 r._init_mesh_cpu_buffer() assert r._mesh_cpu_buf is buf + + +def test_mesh_buffer_overflow_guard(): + """push_tri must stop writing before exceeding MESH_MAX_VERTS. + + Strategy: build a State with enough face-person entries that the old + MESH_MAX_TRIS-only guard would allow n_verts > MESH_MAX_VERTS (old cap + was MESH_MAX_TRIS*3 = 24576), whereas the new guard caps at MESH_MAX_VERTS + (10475). FACE_TRIANGLES has 36 tris → 108 verts per person. + 200 persons × 108 = 21600 verts > 10475, so the vertex guard fires first. + + _update_mesh calls self._mesh_buf.contents().as_buffer() — we stub that + out since no GPU is available in unit tests. + """ + from data_only_viz.renderer import MESH_MAX_VERTS + + r = MetalRenderer.__new__(MetalRenderer) + r._init_mesh_cpu_buffer() + + # Stub _mesh_buf so the GPU upload path does not crash. + fake_mv = bytearray(MESH_MAX_VERTS * 5 * 4) # max possible bytes + + class FakeBuf: + def contents(self): + return self + + def as_buffer(self, n): + return memoryview(fake_mv)[:n] + + r._mesh_buf = FakeBuf() + + # Build a State with 200 face persons, each with 478 keypoints at conf=1.0. + # With FACE_TRIANGLES (36 tris = 108 verts/person), 200 persons would write + # 21600 verts — well past MESH_MAX_VERTS — if the guard were absent. + s = State() + s.pose_last_t = time.monotonic() # make pose_alive() return True + visible_kp = [PoseKp(x=0.5, y=0.5, z=0.0, c=1.0) for _ in range(478)] + s.persons_face = [list(visible_kp) for _ in range(200)] + + n_tris = r._update_mesh(s) + n_verts = n_tris * 3 + + assert n_verts <= MESH_MAX_VERTS, ( + f"Buffer overflow: _update_mesh wrote {n_verts} verts, " + f"but MESH_MAX_VERTS={MESH_MAX_VERTS}" + )