@@ -282,6 +282,92 @@ def readback_texture(self, scene, path):
282282 img .save (str (path ))
283283 return path
284284
285+ def assert_min_fps (self , scene , min_fps = 60 , * , frames = 20 , warmup = 5 , label = None ):
286+ """Assert a scene can render at least *min_fps* frames per second.
287+
288+ This is designed to catch performance regressions (e.g. a
289+ CoefficientFunction being re-evaluated every frame, or an O(n²)
290+ loop that should be O(n)).
291+
292+ Strategy
293+ --------
294+ - Renders *warmup* frames to let JIT/caches/pipelines stabilize.
295+ - Then renders *frames* frames and measures the **median** frame time.
296+ - Asserts that the median frame time is below ``1/min_fps`` seconds.
297+ - Uses median (not mean) so a single GC pause or scheduling hiccup
298+ doesn't cause a false failure.
299+
300+ Choosing ``min_fps``
301+ --------------------
302+ Once a scene is initialized, rendering a frame is just submitting
303+ GPU commands — there is no reason for it to be slow unless something
304+ is being recomputed on the CPU each frame.
305+
306+ - Simple/medium scenes (mesh, CF, vectors): ``min_fps=60`` (default)
307+ - Heavy scenes (3D + clipping, high-order, large meshes): ``min_fps=20``
308+
309+ Parameters
310+ ----------
311+ scene : Scene
312+ The scene to render (must already be initialized with a canvas).
313+ min_fps : float
314+ Minimum acceptable frames per second (median).
315+ frames : int
316+ Number of frames to measure (after warmup).
317+ warmup : int
318+ Number of frames to discard before measurement.
319+ label : str, optional
320+ A descriptive label for the assertion error message.
321+ """
322+ import statistics
323+ import time
324+
325+ assert scene is not None , "Scene is None"
326+ assert scene .canvas is not None , "Scene has no canvas"
327+
328+ # Warmup: pipeline creation, shader compilation, buffer uploads
329+ for _ in range (warmup ):
330+ with scene ._render_mutex :
331+ scene ._render_objects (to_canvas = False )
332+
333+ # Measure individual frame times
334+ frame_times = []
335+ for _ in range (frames ):
336+ t0 = time .perf_counter ()
337+ with scene ._render_mutex :
338+ scene ._render_objects (to_canvas = False )
339+ frame_times .append (time .perf_counter () - t0 )
340+
341+ median_time = statistics .median (frame_times )
342+ measured_fps = 1.0 / median_time if median_time > 0 else float ("inf" )
343+ max_frame_time = 1.0 / min_fps
344+
345+ desc = f" [{ label } ]" if label else ""
346+ assert median_time <= max_frame_time , (
347+ f"Scene{ desc } too slow: { measured_fps :.1f} fps "
348+ f"(median frame { median_time * 1000 :.1f} ms), "
349+ f"minimum required: { min_fps } fps ({ max_frame_time * 1000 :.0f} ms/frame). "
350+ f"Frame times: min={ min (frame_times )* 1000 :.1f} ms, "
351+ f"max={ max (frame_times )* 1000 :.1f} ms, "
352+ f"p90={ sorted (frame_times )[int (len (frame_times )* 0.9 )]* 1000 :.1f} ms"
353+ )
354+
355+
356+ def assert_min_fps (webgpu_env , scene , min_fps = 60 , * , frames = 20 , warmup = 5 , label = None ):
357+ """Standalone convenience wrapper around WebGPUTestEnv.assert_min_fps.
358+
359+ Use this in parametrized tests or when you want a functional call style::
360+
361+ from webgpu.testing import assert_min_fps
362+
363+ def test_my_scene(webgpu_env):
364+ scene = build_scene()
365+ assert_min_fps(webgpu_env, scene, min_fps=60, label="basic triangle")
366+
367+ See :meth:`WebGPUTestEnv.assert_min_fps` for parameter docs.
368+ """
369+ webgpu_env .assert_min_fps (scene , min_fps , frames = frames , warmup = warmup , label = label )
370+
285371
286372# ---------------------------------------------------------------------------
287373# Pytest fixtures
0 commit comments