Decals

projecting textures onto surfaces with Decal3D.

▶ Run in browser

Tags: 3d decals projection pbr

A Decal3D projects a texture onto whatever geometry sits inside its oriented box (design D9), along the box’s local -Y axis, compositing over the surface albedo inside the uber shader without a separate mesh. This scene drops a target ring, a crack, and a warning splat onto a tiled floor, a crate, and a sphere, so the same projector wraps flat, edged, and curved geometry. Decals are zero-cost when unused: with no Decal3D in the scene the projection loop never runs and the frame is byte-identical to plain forward shading.

Usage: uv run python examples/features/3d/decals.py

Controls: Space / Click / Tap - Toggle the decals on/off Escape - Quit

Source

  1"""Decals: projecting textures onto surfaces with Decal3D.
  2
  3A Decal3D projects a texture onto whatever geometry sits inside its oriented box
  4(design D9), along the box's local -Y axis, compositing over the surface albedo
  5inside the uber shader without a separate mesh. This scene drops a target ring, a
  6crack, and a warning splat onto a tiled floor, a crate, and a sphere, so the same
  7projector wraps flat, edged, and curved geometry. Decals are zero-cost when
  8unused: with no Decal3D in the scene the projection loop never runs and the frame
  9is byte-identical to plain forward shading.
 10
 11# /// simvx
 12# tags = ["3d", "decals", "projection", "pbr"]
 13# screenshot_frame = 30
 14# ///
 15
 16Usage:
 17    uv run python examples/features/3d/decals.py
 18
 19Controls:
 20    Space / Click / Tap - Toggle the decals on/off
 21    Escape              - Quit
 22"""
 23
 24import math
 25
 26import numpy as np
 27
 28from simvx.core import (
 29    Camera3D,
 30    Decal3D,
 31    DirectionalLight3D,
 32    Input,
 33    InputMap,
 34    Key,
 35    Material,
 36    Mesh,
 37    MeshInstance3D,
 38    MouseButton,
 39    Node3D,
 40    PointLight3D,
 41    Quat,
 42    Text2D,
 43    Vec3,
 44    WorldEnvironment,
 45)
 46from simvx.graphics import App
 47
 48WIDTH, HEIGHT = 1280, 720
 49_TEX = 256
 50
 51
 52def _target_texture() -> np.ndarray:
 53    """Concentric red/white target rings on a transparent field (RGBA uint8)."""
 54    y, x = np.mgrid[0:_TEX, 0:_TEX].astype(np.float32)
 55    r = np.hypot(x - _TEX / 2, y - _TEX / 2) / (_TEX / 2)
 56    ring = np.sin(r * math.pi * 6.0) * 0.5 + 0.5
 57    img = np.zeros((_TEX, _TEX, 4), dtype=np.uint8)
 58    img[..., 0] = np.clip(180 + ring * 75, 0, 255)
 59    img[..., 1] = np.clip(ring * 60, 0, 255)
 60    img[..., 2] = np.clip(ring * 60, 0, 255)
 61    img[..., 3] = np.where(r <= 1.0, 255, 0).astype(np.uint8)
 62    return img
 63
 64
 65def _crack_texture() -> np.ndarray:
 66    """A dark branching crack decal (RGBA uint8, mostly transparent)."""
 67    img = np.zeros((_TEX, _TEX, 4), dtype=np.uint8)
 68    y, x = np.mgrid[0:_TEX, 0:_TEX].astype(np.float32)
 69    cx, cy = _TEX / 2, _TEX / 2
 70    for ang in np.linspace(0.0, math.tau, 7, endpoint=False):
 71        dx, dy = math.cos(ang), math.sin(ang)
 72        # Distance from the ray starting at centre in direction (dx, dy).
 73        perp = np.abs((x - cx) * dy - (y - cy) * dx)
 74        along = (x - cx) * dx + (y - cy) * dy
 75        width = 2.5 + along * 0.02
 76        mask = (perp < width) & (along > 0) & (along < _TEX * 0.5)
 77        img[..., 3] = np.maximum(img[..., 3], np.where(mask, 235, 0).astype(np.uint8))
 78    img[..., 0] = 25
 79    img[..., 1] = 22
 80    img[..., 2] = 20
 81    return img
 82
 83
 84def _splat_texture() -> np.ndarray:
 85    """A yellow warning hazard splat (RGBA uint8)."""
 86    y, x = np.mgrid[0:_TEX, 0:_TEX].astype(np.float32)
 87    r = np.hypot(x - _TEX / 2, y - _TEX / 2) / (_TEX / 2)
 88    ang = np.arctan2(y - _TEX / 2, x - _TEX / 2)
 89    lobed = r + 0.12 * np.sin(ang * 8.0)
 90    stripes = ((x + y) / 26.0).astype(np.int32) % 2
 91    img = np.zeros((_TEX, _TEX, 4), dtype=np.uint8)
 92    img[..., 0] = np.where(stripes == 0, 240, 30).astype(np.uint8)
 93    img[..., 1] = np.where(stripes == 0, 200, 25).astype(np.uint8)
 94    img[..., 2] = 20
 95    img[..., 3] = np.where(lobed <= 0.95, 255, 0).astype(np.uint8)
 96    return img
 97
 98
 99class DecalScene(Node3D):
100    def __init__(self, **kwargs):
101        super().__init__(name="DecalDemo", **kwargs)
102
103        self.camera = self.add_child(Camera3D(name="Camera", fov=50, near=0.1, far=120.0))
104        self.camera.position = Vec3(0.0, 6.5, 9.5)
105        self.camera.look_at(Vec3(0.0, 0.2, 0.0))
106
107        sun = self.add_child(DirectionalLight3D(name="Sun"))
108        sun.colour = (1.0, 0.97, 0.92)
109        sun.intensity = 2.6
110        sun.rotation = Quat.from_euler(math.radians(-58), math.radians(28), 0)
111
112        fill = self.add_child(PointLight3D(name="Fill", position=Vec3(-6, 6, 6)))
113        fill.colour = (0.6, 0.7, 1.0)
114        fill.intensity = 0.6
115        fill.range = 40.0
116
117        # Tiled matte floor (flat receiver).
118        self.add_child(
119            MeshInstance3D(
120                name="Floor",
121                mesh=Mesh.cube(1.0),
122                material=Material(colour=(0.55, 0.56, 0.58), metallic=0.0, roughness=0.85),
123                position=Vec3(0, -0.15, 0),
124                scale=Vec3(18, 0.3, 18),
125            )
126        )
127        # A crate (edged receiver) and a sphere (curved receiver).
128        self.add_child(
129            MeshInstance3D(
130                name="Crate",
131                mesh=Mesh.cube(2.2),
132                material=Material(colour=(0.42, 0.30, 0.20), metallic=0.0, roughness=0.7),
133                position=Vec3(-3.4, 1.1, -0.5),
134                rotation=Quat.from_euler(0, math.radians(18), 0),
135            )
136        )
137        self.add_child(
138            MeshInstance3D(
139                name="Ball",
140                mesh=Mesh.sphere(1.4, rings=28, segments=40),
141                material=Material(colour=(0.75, 0.75, 0.8), metallic=0.05, roughness=0.4),
142                position=Vec3(3.4, 1.4, -0.3),
143            )
144        )
145
146        # --- Decals ---
147        # Target ring projected straight down onto the floor centre.
148        self._target = Decal3D(name="TargetDecal", position=Vec3(0.0, 1.0, 1.6))
149        self._target.texture = _target_texture()
150        self._target.size = Vec3(1.3, 0.7, 1.3)
151        self._target.albedo_mix = 1.0
152        self.add_child(self._target)
153
154        # Crack projected down onto the crate's top + side (edged geometry).
155        self._crack = Decal3D(name="CrackDecal", position=Vec3(-3.4, 2.6, -0.5))
156        self._crack.texture = _crack_texture()
157        self._crack.size = Vec3(0.9, 0.8, 0.9)
158        self._crack.albedo_mix = 0.95
159        self.add_child(self._crack)
160
161        # Hazard splat projected onto the sphere (curved geometry) from above.
162        self._splat = Decal3D(name="SplatDecal", position=Vec3(3.4, 3.0, -0.3))
163        self._splat.texture = _splat_texture()
164        self._splat.size = Vec3(0.75, 1.0, 0.75)
165        self._splat.albedo_mix = 1.0
166        self.add_child(self._splat)
167
168        self._decals = [self._target, self._crack, self._splat]
169        self._on = True
170
171        env = self.add_child(WorldEnvironment(name="Env"))
172        env.ambient_light_colour = (0.20, 0.21, 0.24)
173        env.ambient_light_energy = 0.7
174
175        self._cooldown = 0.0
176        self.add_child(Text2D(text="DECALS", position=(10, 8), font_scale=1.5))
177        self._status = self.add_child(Text2D(text="DECALS: ON", position=(10, 40), font_scale=1.3))
178        self._hint = self.add_child(
179            Text2D(text="SPACE/CLICK:Toggle   ESC:Quit", position=(10, HEIGHT - 30), font_scale=1.1)
180        )
181
182    def on_ready(self):
183        InputMap.add_action("toggle_decals", [Key.SPACE, MouseButton.LEFT])
184        InputMap.add_action("quit", [Key.ESCAPE])
185
186    def on_update(self, dt: float):
187        # Keep the controls hint pinned to the live window's bottom edge.
188        self._hint.position = (10, self.app.height - 30)
189        self._cooldown = max(0.0, self._cooldown - dt)
190        if Input.is_action_just_pressed("toggle_decals") and self._cooldown <= 0.0:
191            self._on = not self._on
192            self._cooldown = 0.3
193            for d in self._decals:
194                d.visible = self._on
195            self._status.text = f"DECALS: {'ON' if self._on else 'OFF'}"
196        if Input.is_action_just_pressed("quit"):
197            self.app.quit()
198
199
200def main():
201    scene = DecalScene()
202    app = App(title="SimVX Decals Demo", width=WIDTH, height=HEIGHT, physics_fps=60)
203    app.run(scene)
204
205
206if __name__ == "__main__":
207    main()