Noise

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

▶ Run in browser

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