TAA

Temporal anti-aliasing stabilises a high-frequency scene under an orbiting camera.

▶ Run in browser

Tags: 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)