Noise¶
Perlin, Simplex, Value, and Cellular noise side by side.
▶ Run in browserTags: 3d
Animates noise by slowly changing z-coordinate over time. Each quadrant shows a different noise type rendered as a grayscale texture on a textured quad.
Usage: uv run python examples/features/3d/noise.py
Source¶
1#!/usr/bin/env python3
2"""Noise: Perlin, Simplex, Value, and Cellular noise side by side.
3
4# /// simvx
5# web = { width = 1280, height = 720 }
6# ///
7
8Animates noise by slowly changing z-coordinate over time. Each quadrant shows a
9different noise type rendered as a grayscale texture on a textured quad.
10
11Usage:
12 uv run python examples/features/3d/noise.py
13"""
14
15import numpy as np
16
17from simvx.core import (
18 Camera3D,
19 DirectionalLight3D,
20 Input,
21 InputMap,
22 Key,
23 Material,
24 Mesh,
25 MeshInstance3D,
26 Node,
27 Text2D,
28 Vec3,
29)
30from simvx.core.noise import FastNoiseLite, FractalType, NoiseType
31from simvx.graphics import App
32
33RESOLUTION = 256
34NOISE_SCALE = 0.04
35QUAD_SPACING = 5.5
36
37
38def _noise_to_rgba(data: np.ndarray) -> np.ndarray:
39 """Convert float noise in [-1, 1] to RGBA uint8."""
40 normalized = ((data + 1.0) * 0.5 * 255).clip(0, 255).astype(np.uint8)
41 h, w = normalized.shape
42 rgba = np.zeros((h, w, 4), dtype=np.uint8)
43 rgba[:, :, 0] = normalized
44 rgba[:, :, 1] = normalized
45 rgba[:, :, 2] = normalized
46 rgba[:, :, 3] = 255
47 return rgba
48
49
50class NoiseDemo(Node):
51 """Renders four noise types as animated textured quads."""
52
53 def on_ready(self):
54 InputMap.add_action("quit", [Key.ESCAPE])
55
56 # Camera looks straight at the XY plane from +Z; quads sit in that
57 # plane with their +Z-facing normals toward the camera.
58 cam = Camera3D(position=(0, 0, 12), fov=55)
59 cam.look_at(Vec3(0, 0, 0), up=Vec3(0, 1, 0))
60 self.add_child(cam)
61
62 # Without a light the textured quads look fine with albedo, but a
63 # directional light keeps them consistent with the other 3D demos.
64 sun = self.add_child(DirectionalLight3D(name="Sun", intensity=1.0))
65 sun.direction = Vec3(0.3, -0.5, -1.0)
66
67 self._z_offset = 0.0
68 self._generators: list[tuple[str, FastNoiseLite]] = []
69 self._tex_arrays: list[np.ndarray] = []
70 self._quads: list[MeshInstance3D] = []
71 self._materials: list[Material] = []
72
73 noise_configs = [
74 ("Perlin", NoiseType.PERLIN),
75 ("Simplex", NoiseType.SIMPLEX),
76 ("Value", NoiseType.VALUE),
77 ("Cellular", NoiseType.CELLULAR),
78 ]
79
80 quad_mesh = Mesh(
81 positions=[[-1, -1, 0], [1, -1, 0], [1, 1, 0], [-1, 1, 0]],
82 indices=[0, 1, 2, 0, 2, 3],
83 normals=[[0, 0, 1]] * 4,
84 texcoords=[[0, 0], [1, 0], [1, 1], [0, 1]],
85 )
86 # 2x2 grid in the XY plane: camera looks at them face-on along -Z.
87 positions = [
88 (-QUAD_SPACING / 2, QUAD_SPACING / 2, 0),
89 ( QUAD_SPACING / 2, QUAD_SPACING / 2, 0),
90 (-QUAD_SPACING / 2, -QUAD_SPACING / 2, 0),
91 ( QUAD_SPACING / 2, -QUAD_SPACING / 2, 0),
92 ]
93
94 for idx, (label, nt) in enumerate(noise_configs):
95 gen = FastNoiseLite(seed=42, noise_type=nt, frequency=NOISE_SCALE)
96 gen.fractal_type = FractalType.FBM
97 gen.fractal_octaves = 4
98 self._generators.append((label, gen))
99
100 # Generate the initial texture directly as an RGBA ndarray: no
101 # PIL, no PNG round-trip. TextureManager.resolve() accepts
102 # ndarrays and caches by id(array), so mutating the array in
103 # process() would NOT re-upload; we replace the whole array.
104 img_data = gen.get_image(RESOLUTION, RESOLUTION, scale=1.0)
105 tex_arr = _noise_to_rgba(img_data)
106 self._tex_arrays.append(tex_arr)
107
108 mat = Material(colour=(1, 1, 1, 1), albedo_map=tex_arr)
109 self._materials.append(mat)
110 quad = MeshInstance3D(
111 mesh=quad_mesh,
112 material=mat,
113 position=positions[idx],
114 scale=(2.2, 2.2, 2.2),
115 )
116 self._quads.append(quad)
117 self.add_child(quad)
118
119 # Label: single top title naming the four noise types (screen-space HUD).
120 label = "Noise Demo: Perlin / Simplex / Value / Cellular (FBM)"
121 self.add_child(Text2D(text=label, x=10, y=10, font_scale=1.5))
122 self._frame_count = 0
123
124 def on_process(self, dt):
125 if Input.is_action_just_pressed("quit"):
126 self.app.quit()
127 return
128 self._z_offset += dt * 0.5
129 self._frame_count += 1
130 # Update textures every 6 frames to keep framerate reasonable
131 if self._frame_count % 6 != 0:
132 return
133 for idx, (_label, gen) in enumerate(self._generators):
134 xs = np.arange(RESOLUTION, dtype=np.float64)
135 ys = np.arange(RESOLUTION, dtype=np.float64)
136 yy, xx = np.meshgrid(ys, xs, indexing="ij")
137 xs_flat = xx.ravel()
138 ys_flat = yy.ravel()
139 zs_flat = np.full_like(xs_flat, self._z_offset)
140 img_data = gen.get_noise_3d_array(xs_flat, ys_flat, zs_flat)
141 img_2d = img_data.reshape(RESOLUTION, RESOLUTION)
142 new_arr = _noise_to_rgba(img_2d)
143 # Fresh ndarray => fresh TextureManager cache entry => re-upload.
144 self._tex_arrays[idx] = new_arr
145 self._materials[idx].albedo_uri = new_arr
146
147
148if __name__ == "__main__":
149 app = App(title="Noise Demo", width=1280, height=720)
150 app.run(NoiseDemo())