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