Noise

Perlin, Simplex, Value, and Cellular noise side by side.

▶ Run in browser

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