Decals

projecting textures onto surfaces with Decal3D.

▶ Run in browser

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