Decals¶
projecting textures onto surfaces with Decal3D.
▶ Run in browserTags: 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()