Decals¶
projecting textures onto surfaces with Decal3D.
▶ Run in browserTags: 3d decals projection pbr
A Decal3D paints a texture onto whatever geometry lies inside its box,
projecting along the node’s local -Y axis. There is no extra mesh: the
projected colour blends over each surface’s own albedo as it is shaded, so a
decal follows the shape of what it lands on.
Set the box with size, its full x, y and z extents centred on the node, where
y is how far the projection reaches. Set the image with texture (a file path or an RGBA array) and the
strength with albedo_mix (0 to 1). Point the projection by rotating the node,
and parent it to the object it marks so it moves with that object.
This scene projects three generated images:
a target ring straight down onto the floor,
a crack onto the crate’s front face, the decal rotated so its -Y points into the face and parented to the crate so it shares the crate’s turn,
a hazard splat down onto the top of the sphere, where it wraps the curve.
Press Space, click or tap to hide and show every decal. A scene with no visible decal skips the projection entirely.
Source¶
1# /// simvx
2# tags = ["3d", "decals", "projection", "pbr"]
3# screenshot_frame = 30
4# ///
5"""Decals: projecting textures onto surfaces with Decal3D.
6
7A `Decal3D` paints a texture onto whatever geometry lies inside its box,
8projecting along the node's local -Y axis. There is no extra mesh: the
9projected colour blends over each surface's own albedo as it is shaded, so a
10decal follows the shape of what it lands on.
11
12Set the box with `size`, its full x, y and z extents centred on the node, where
13y is how far the projection reaches. Set the image with `texture` (a file path or an RGBA array) and the
14strength with `albedo_mix` (0 to 1). Point the projection by rotating the node,
15and parent it to the object it marks so it moves with that object.
16
17This scene projects three generated images:
18
19- a target ring straight down onto the floor,
20- a crack onto the crate's front face, the decal rotated so its -Y points into
21 the face and parented to the crate so it shares the crate's turn,
22- a hazard splat down onto the top of the sphere, where it wraps the curve.
23
24Press Space, click or tap to hide and show every decal. A scene with no
25visible decal skips the projection entirely.
26"""
27
28import math
29
30import numpy as np
31
32from simvx.core import (
33 Anchor,
34 Camera3D,
35 CanvasLayer,
36 Decal3D,
37 DirectionalLight3D,
38 Key,
39 Label,
40 Material,
41 Mesh,
42 MeshInstance3D,
43 Node3D,
44 Pointer,
45 PointLight3D,
46 Quat,
47 WorldEnvironment,
48 on_input,
49)
50from simvx.graphics import App
51
52# Pixel coordinates of a 256 x 256 image, centred: radius 1 touches the edges.
53ys, xs = (np.mgrid[0:256, 0:256] - 127.5) / 128.0
54radius, angle = np.hypot(xs, ys), np.arctan2(ys, xs)
55
56
57def rgba(rgb, alpha) -> np.ndarray:
58 """Stack colour channels and an alpha mask (all 0 to 1) into an RGBA image."""
59 channels = [np.broadcast_to(c, radius.shape) for c in (*rgb, alpha)]
60 return (np.stack(channels, axis=-1) * 255).astype(np.uint8)
61
62
63ring = np.sin(radius * 6 * math.pi) * 0.5 + 0.5
64TARGET = rgba((0.7 + 0.3 * ring, 0.24 * ring, 0.24 * ring), radius <= 0.95)
65
66# Seven dark spokes that widen towards their tips.
67spoke = np.abs((angle % (math.tau / 7)) - math.tau / 14) * radius * 128
68CRACK = rgba((0.1, 0.09, 0.08), (spoke < 2.5 + radius * 2.5) & (radius < 1))
69
70# Diagonal yellow and black stripes inside a blob with eight lobes.
71stripe = (xs + ys) * 128 // 26 % 2 == 0
72blob = radius + 0.12 * np.sin(angle * 8) <= 0.95
73SPLAT = rgba((np.where(stripe, 0.94, 0.12), np.where(stripe, 0.78, 0.1), 0.08), blob)
74
75
76class DecalScene(Node3D):
77 input_context = {"toggle_decals": [Key.SPACE, Pointer.PRIMARY]}
78
79 def on_ready(self):
80 self.add_child(Camera3D(fov=50, position=(0, 6.5, 9.5), look_at=(0, 0.2, 0)))
81 self.add_child(DirectionalLight3D(colour=(1.0, 0.97, 0.92), intensity=2.6, direction=(-0.25, -0.85, -0.47)))
82 self.add_child(PointLight3D(colour=(0.6, 0.7, 1.0), intensity=0.6, range=40.0, position=(-6, 6, 6)))
83 self.add_child(WorldEnvironment(ambient_light_colour=(0.2, 0.21, 0.24), ambient_light_energy=0.7))
84
85 floor = self.add_child(
86 MeshInstance3D(mesh=Mesh.plane(18.0), material=Material(colour=(0.55, 0.56, 0.58), roughness=0.85))
87 )
88 crate = self.add_child(
89 MeshInstance3D(
90 mesh=Mesh.box(2.2),
91 material=Material(colour=(0.42, 0.3, 0.2), roughness=0.7),
92 position=(-3.4, 1.1, -0.5),
93 rotation=Quat.from_euler(yaw=math.radians(18)),
94 )
95 )
96 ball = self.add_child(
97 MeshInstance3D(
98 mesh=Mesh.sphere(1.4, rings=28, segments=40),
99 material=Material(colour=(0.75, 0.75, 0.8), metallic=0.05, roughness=0.4),
100 position=(3.4, 1.4, -0.3),
101 )
102 )
103
104 self.decals = [
105 floor.add_child(Decal3D(texture=TARGET, size=(2.6, 1.0, 2.6), position=(0, 0, 1.6))),
106 # On the crate's front face, pitched a quarter turn so its -Y points into the crate.
107 crate.add_child(
108 Decal3D(
109 texture=CRACK,
110 albedo_mix=0.9,
111 size=(1.8, 1.0, 1.8),
112 position=(0, 0, 1.1),
113 rotation=Quat.from_euler(pitch=math.pi / 2),
114 )
115 ),
116 ball.add_child(Decal3D(texture=SPLAT, size=(1.5, 1.6, 1.5), position=(0, 1.4, 0))),
117 ]
118
119 hud = self.add_child(CanvasLayer())
120 self.status = hud.add_child(Label("Decals: on", anchor=Anchor.TOP_LEFT, inset=12, font_size=24))
121 hud.add_child(Label("Space / click / tap: toggle the decals", anchor=Anchor.BOTTOM_LEFT, inset=12))
122
123 @on_input("toggle_decals")
124 def toggle_decals(self, _event):
125 shown = not self.decals[0].visible
126 for decal in self.decals:
127 decal.visible = shown
128 self.status.text = f"Decals: {'on' if shown else 'off'}"
129
130
131if __name__ == "__main__":
132 App(title="Decals", width=1280, height=720).run(DecalScene())