Noise

Play Demo

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