TAA¶
Temporal anti-aliasing stabilises a high-frequency scene under an orbiting camera.
▶ Run in browserTags: 3d
Temporal anti-aliasing jitters the camera sub-pixel each frame and accumulates the result into a history buffer, reprojecting it through PER-PIXEL motion so the accumulation tracks both camera AND moving-object motion. The scene below packs thin edges and a checker floor (high-frequency detail that aliases badly) and orbits the camera so the difference between TAA on/off is visible: edges crawl and shimmer with TAA off, and resolve to stable smooth lines with TAA on.
A fast-moving emissive cube sweeps across the frame: with only camera-motion reprojection it would smear a ghost trail behind itself, but the per-object velocity buffer reprojects its history through its own motion, so the trailing edge stays clean (no smear).
TAA is OFF by default engine-wide (it costs a history buffer + resolve pass); this
demo opts in via WorldEnvironment.taa_enabled = True.
Controls: T : Toggle TAA on/off A/D : Slow down / speed up the orbit ESC : Quit
Usage: uv run python examples/features/3d/taa.py uv run python examples/features/3d/taa.py –test
Source¶
1#!/usr/bin/env python3
2"""TAA: Temporal anti-aliasing stabilises a high-frequency scene under an orbiting camera.
3
4# /// simvx
5# screenshot_frame = 90
6# web = { width = 1280, height = 720, root = "TAADemo", responsive = true }
7# ///
8
9Temporal anti-aliasing jitters the camera sub-pixel each frame and accumulates
10the result into a history buffer, reprojecting it through PER-PIXEL motion so the
11accumulation tracks both camera AND moving-object motion. The scene below packs
12thin edges and a checker floor (high-frequency detail that aliases badly) and
13orbits the camera so the difference between TAA on/off is visible: edges crawl
14and shimmer with TAA off, and resolve to stable smooth lines with TAA on.
15
16A fast-moving emissive cube sweeps across the frame: with only camera-motion
17reprojection it would smear a ghost trail behind itself, but the per-object
18velocity buffer reprojects its history through its own motion, so the trailing
19edge stays clean (no smear).
20
21TAA is OFF by default engine-wide (it costs a history buffer + resolve pass); this
22demo opts in via ``WorldEnvironment.taa_enabled = True``.
23
24Controls:
25 T : Toggle TAA on/off
26 A/D : Slow down / speed up the orbit
27 ESC : Quit
28
29Usage:
30 uv run python examples/features/3d/taa.py
31 uv run python examples/features/3d/taa.py --test
32"""
33
34import math
35import sys
36
37from simvx.core import (
38 Camera3D,
39 DirectionalLight3D,
40 Input,
41 InputMap,
42 Key,
43 Material,
44 Mesh,
45 MeshInstance3D,
46 Node,
47 Property,
48 Text2D,
49 Vec3,
50 WorldEnvironment,
51)
52from simvx.graphics import App
53
54WIDTH, HEIGHT = 1280, 720
55
56
57class TAADemo(Node):
58 """Orbiting camera over a high-frequency scene to demonstrate TAA stability."""
59
60 orbit_speed = Property(0.8, range=(0.1, 5.0))
61 orbit_radius = Property(9.0, range=(3.0, 20.0))
62
63 def __init__(self, **kwargs):
64 super().__init__(**kwargs)
65 self._time = 0.0
66 self._taa_on = True
67 # When set (headless ghosting test), freeze the camera so the moving cube
68 # is the ONLY motion: any trail it leaves is per-object ghosting, not
69 # camera reprojection error.
70 self._freeze_camera = False
71
72 def on_ready(self):
73 super().on_ready()
74
75 InputMap.add_action("toggle_taa", [Key.T])
76 InputMap.add_action("orbit_faster", [Key.D])
77 InputMap.add_action("orbit_slower", [Key.A])
78 InputMap.add_action("quit", [Key.ESCAPE])
79
80 self.camera = self.add_child(Camera3D(position=Vec3(0.0, 4.0, 9.0), look_at=Vec3(0.0, 0.5, 0.0)))
81
82 light = DirectionalLight3D()
83 light.direction = Vec3(-0.5, -1.0, -0.3)
84 light.colour = (1.0, 0.97, 0.92)
85 light.intensity = 1.6
86 self.add_child(light)
87
88 # Checker floor: a grid of alternating dark/light tiles. The tile edges
89 # are exactly the kind of high-frequency horizontal detail that crawls
90 # under camera motion without temporal accumulation.
91 tile = Mesh.cube()
92 for gx in range(-6, 6):
93 for gz in range(-6, 6):
94 light_tile = (gx + gz) % 2 == 0
95 col = (0.85, 0.85, 0.88) if light_tile else (0.12, 0.12, 0.15)
96 t = MeshInstance3D(mesh=tile, material=Material(colour=col, roughness=0.85))
97 t.position = Vec3(gx + 0.5, -0.55, gz + 0.5)
98 t.scale = Vec3(1.0, 0.1, 1.0)
99 self.add_child(t)
100
101 # A picket of thin tall pillars: near-vertical edges that shimmer badly
102 # when undersampled. TAA should resolve these to clean stable lines.
103 for i in range(-4, 5):
104 pillar = MeshInstance3D(
105 mesh=tile,
106 material=Material(colour=(0.9, 0.5 + 0.05 * i, 0.2), roughness=0.4, metallic=0.1),
107 )
108 pillar.position = Vec3(i * 1.1, 1.2, -2.0)
109 pillar.scale = Vec3(0.12, 2.4, 0.12)
110 self.add_child(pillar)
111
112 # A metallic sphere for a smooth-shaded reference.
113 sphere = MeshInstance3D(
114 mesh=Mesh.sphere(),
115 material=Material(colour=(0.1, 0.5, 0.9), roughness=0.2, metallic=0.8),
116 )
117 sphere.position = Vec3(2.5, 1.0, 1.5)
118 self.add_child(sphere)
119
120 # A fast-moving bright cube: the per-object velocity buffer reprojects its
121 # TAA history through its OWN motion, so its trailing edge stays clean.
122 # Without per-object velocity (camera-only reproject) it would smear a
123 # ghost trail. It sweeps left<->right low over the dark checker, in a
124 # distinct emissive magenta so it is unambiguous against the orange picket.
125 self._mover = MeshInstance3D(
126 mesh=tile,
127 material=Material(colour=(1.0, 0.1, 0.8), roughness=0.3, emissive_colour=(1.0, 0.05, 0.7)),
128 )
129 self._mover.scale = Vec3(0.45, 0.45, 0.45)
130 self._mover.position = Vec3(0.0, 0.4, 3.0)
131 self.add_child(self._mover)
132
133 self._env = self.add_child(WorldEnvironment(name="Env"))
134 self._env.taa_enabled = self._taa_on
135
136 # Top-left status + bottom-left controls (font sizes match the sibling
137 # 3D feature examples; HiDPI scaling is handled by the engine).
138 self._hud_taa = self.add_child(Text2D(text="TAA: ON", x=12, y=12, font_scale=1.8))
139 self._hud_speed = self.add_child(Text2D(text="Orbit: 0.8 (A/D)", x=12, y=52, font_scale=1.4))
140 self.add_child(Text2D(text="T: Toggle TAA", x=12, y=HEIGHT - 34, font_scale=1.4))
141
142 def on_process(self, dt: float):
143 if Input.is_action_just_pressed("quit"):
144 self.app.quit()
145 return
146
147 self._time += dt
148
149 # Sweep the bright cube horizontally (fast, large screen-space motion):
150 # its trailing edge ghosts under camera-only TAA, stays clean with
151 # per-object velocity.
152 self._mover.position = Vec3(math.sin(self._time * 2.2) * 3.4, 0.4, 3.0)
153
154 if not self._freeze_camera:
155 angle = self._time * self.orbit_speed
156 x = math.cos(angle) * self.orbit_radius
157 z = math.sin(angle) * self.orbit_radius
158 y = 3.5 + math.sin(self._time * 0.4) * 1.2
159 self.camera.position = Vec3(x, y, z)
160 self.camera.look_at(Vec3(0.0, 0.6, 0.0))
161
162 if Input.is_action_just_pressed("toggle_taa"):
163 self._taa_on = not self._taa_on
164 if Input.is_action_pressed("orbit_faster"):
165 self.orbit_speed = min(5.0, self.orbit_speed + 1.5 * dt)
166 if Input.is_action_pressed("orbit_slower"):
167 self.orbit_speed = max(0.1, self.orbit_speed - 1.5 * dt)
168
169 self._env.taa_enabled = self._taa_on
170 self._hud_taa.text = f"TAA: {'ON' if self._taa_on else 'OFF'}"
171 self._hud_speed.text = f"Orbit: {self.orbit_speed:.1f} (A/D)"
172
173
174def _run_test() -> None:
175 """Headless: render with TAA ON, assert non-blank, and that a static-edge
176 region stabilises over time (less frame-to-frame variance than TAA off)."""
177 import numpy as np
178
179 from simvx.graphics.testing import assert_not_blank, save_png
180
181 def render_run(taa_on: bool):
182 scene = TAADemo(name="TAADemo")
183 scene._taa_on = taa_on
184 app = App(title="TAA Demo", width=WIDTH, height=HEIGHT, visible=False)
185 frames = app.run_headless(scene, frames=90, capture_frames=[70, 78, 86])
186 return [np.asarray(f, dtype=np.float32) for f in frames]
187
188 on_frames = render_run(True)
189 for i, f in enumerate(on_frames):
190 save_png(f"/tmp/taa_test_on_{i}.png", f.astype("uint8"))
191 assert_not_blank(f)
192 print(f"TAA ON: captured {len(on_frames)} non-blank frames")
193
194 off_frames = render_run(False)
195 for i, f in enumerate(off_frames):
196 save_png(f"/tmp/taa_test_off_{i}.png", f.astype("uint8"))
197 assert_not_blank(f)
198
199 # Frame-to-frame luminance variance over the picket region (upper-centre
200 # band where the thin pillars + their edges sit). TAA accumulation should
201 # reduce temporal shimmer vs no TAA. We compare the mean absolute
202 # inter-frame delta in that band.
203 def band_temporal_delta(frames):
204 h, w = frames[0].shape[:2]
205 y0, y1 = int(h * 0.25), int(h * 0.55)
206 x0, x1 = int(w * 0.30), int(w * 0.70)
207 lum = [f[y0:y1, x0:x1, :3].mean(axis=2) for f in frames]
208 deltas = [np.abs(lum[i + 1] - lum[i]).mean() for i in range(len(lum) - 1)]
209 return float(np.mean(deltas))
210
211 on_delta = band_temporal_delta(on_frames)
212 off_delta = band_temporal_delta(off_frames)
213 print(f"Picket-band temporal delta: TAA on={on_delta:.3f}, off={off_delta:.3f}")
214 # TAA should not be *more* shimmery than no-TAA. Camera motion dominates the
215 # absolute delta (both runs orbit identically), so we assert a soft bound:
216 # TAA's accumulation keeps the edge band at least as stable as raw rendering.
217 assert on_delta <= off_delta * 1.10 + 0.5, (
218 f"TAA did not stabilise the edge band (on={on_delta:.3f} > off={off_delta:.3f})"
219 )
220 print("TAA edge-band stability: PASSED")
221
222 _test_moving_mesh_no_ghost()
223
224
225def _test_moving_mesh_no_ghost() -> None:
226 """Per-object velocity: a fast magenta cube under TAA must not smear a ghost
227 trail behind itself, AND the resolve must actually be sampling the velocity
228 buffer (not silently falling back to camera-only depth reprojection).
229
230 The cube sweeps fast while the camera orbits. We verify two things end-to-end:
231
232 1. WIRING: the resolve's per-object velocity path is engaged -- the TAA
233 pass reports a real velocity view bound (``has_velocity``) once the
234 velocity pass has run. This proves the buffer is feeding reprojection.
235 2. RESULT: the band the cube just vacated converges to clean background
236 (magenta is the cube's unique signal, so a residual magenta fraction
237 there would be a ghost smear). Per-object velocity reprojects the cube's
238 history through its own motion so the trailing edge tracks the geometry.
239
240 (The YCoCg neighborhood clamp is the first line of defence against gross
241 colour ghosts; per-object velocity refines WHERE valid history is fetched so
242 the moving mesh stays correctly reprojected rather than reprojected as static.)
243 """
244 import numpy as np
245
246 from simvx.graphics import App
247 from simvx.graphics.testing import save_png
248
249 scene = TAADemo(name="TAAGhost")
250 scene._taa_on = True
251 app = App(title="TAA Ghost", width=WIDTH, height=HEIGHT, visible=False)
252
253 wiring: dict[str, bool] = {}
254
255 def on_frame(idx: int, _t: float) -> None:
256 # By a late frame the velocity pass has run and the resolve has bound its
257 # RG16F view: confirm the per-object path is live (has_velocity True).
258 if idx == 88 and app._engine is not None:
259 r = app._engine.renderer
260 taa = r._taa_pass
261 vpass = r._velocity_pass
262 wiring["velocity_pass_ran"] = bool(vpass is not None and vpass.ready)
263 wiring["resolve_has_velocity"] = bool(taa is not None and taa._has_velocity)
264
265 # The cube sweeps as sin(t*2.2)*3.4: capture a fast left-crossing of centre
266 # after TAA has converged so the small cube fully clears its old footprint.
267 frames = app.run_headless(scene, frames=92, capture_frames=[84, 90], on_frame=on_frame)
268 imgs = [np.asarray(f, dtype=np.float32) for f in frames]
269 for i, f in enumerate(imgs):
270 save_png(f"/tmp/taa_ghost_{i}.png", f.astype("uint8"))
271
272 # (1) Wiring: the resolve must be sampling the per-object velocity buffer.
273 print(f"Velocity pass ran: {wiring.get('velocity_pass_ran')}; "
274 f"resolve has_velocity: {wiring.get('resolve_has_velocity')}")
275 assert wiring.get("velocity_pass_ran"), "velocity pass never ran under TAA"
276 assert wiring.get("resolve_has_velocity"), (
277 "TAA resolve did not bind the per-object velocity view -- it is still on "
278 "the camera-only depth fallback"
279 )
280
281 h, w = imgs[0].shape[:2]
282
283 def magenta_mask(img):
284 r, g, b = img[:, :, 0], img[:, :, 1], img[:, :, 2]
285 return (r > 140) & (g < 110) & (b > 110)
286
287 def magenta_centre_x(img):
288 m = magenta_mask(img)
289 cols = m.sum(axis=0)
290 if cols.sum() == 0:
291 return None
292 return float((np.arange(w) * cols).sum() / cols.sum())
293
294 cx_first = magenta_centre_x(imgs[0])
295 cx_last = magenta_centre_x(imgs[-1])
296 assert cx_first is not None and cx_last is not None, "moving cube not visible in capture"
297 moved = abs(cx_last - cx_first)
298 print(f"Cube magenta centre-x: {cx_first:.0f} -> {cx_last:.0f} (moved {moved:.0f}px)")
299 assert moved > 20, f"cube did not move enough to test ghosting (moved {moved:.0f}px)"
300
301 # (2) Result: the trailing band, just BEYOND the cube's current footprint on
302 # the side it came from, must be clean background (no magenta smear).
303 last_cols = magenta_mask(imgs[-1]).any(axis=0)
304 cube_xs = np.where(last_cols)[0]
305 cube_x0, cube_x1 = int(cube_xs.min()), int(cube_xs.max())
306 coming_from_right = cx_first > cx_last # cube moved left -> trail is to the right
307 gap = 6
308 if coming_from_right:
309 band_x0, band_x1 = min(cube_x1 + gap, w - 2), min(cube_x1 + 70, w - 1)
310 else:
311 band_x0, band_x1 = max(cube_x0 - 70, 0), max(cube_x0 - gap, 1)
312 band_x0, band_x1 = sorted((band_x0, band_x1))
313 band_x1 = max(band_x1, band_x0 + 1)
314 band_y0, band_y1 = int(h * 0.42), int(h * 0.80)
315 vacated = imgs[-1][band_y0:band_y1, band_x0:band_x1]
316 trail_frac = float(magenta_mask(vacated).mean())
317 print(f"Cube footprint x=[{cube_x0},{cube_x1}], vacated band x=[{band_x0},{band_x1}]")
318 print(f"Trailing-band magenta fraction (vacated region): {trail_frac:.3f}")
319 assert trail_frac < 0.05, (
320 f"moving cube left a magenta ghost trail (fraction={trail_frac:.3f}) in the "
321 "region it vacated"
322 )
323 print("Moving-mesh per-object ghosting: PASSED (velocity bound, trailing edge clean)")
324
325
326if __name__ == "__main__":
327 if "--test" in sys.argv:
328 _run_test()
329 else:
330 scene = TAADemo(name="TAADemo")
331 app = App(title="TAA Demo", width=WIDTH, height=HEIGHT)
332 app.run(scene)