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