replace noise with tileable perlin; use pixel-space sampling for fixed scale

This commit is contained in:
Eric Smith 2026-05-20 21:37:07 -04:00
parent 9adba9d6c2
commit d636d0f847
4 changed files with 170 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Before After
Before After

153
generate_noise.py Normal file
View file

@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Generate a tileable 100×100 Perlin noise texture for V Panel."""
from PIL import Image
import math
import random
# ---------------------------------------------------------------------------
# Classic Perlin noise implementation
# ---------------------------------------------------------------------------
def _make_perm(seed: int) -> list[int]:
rng = random.Random(seed)
perm = list(range(256))
rng.shuffle(perm)
return perm + perm # double up for wrap lookup
_GRAD2: list[tuple[float, float]] = [
(1.0, 0.0), (-1.0, 0.0),
(0.0, 1.0), (0.0, -1.0),
(1.0, 1.0), (-1.0, 1.0),
(1.0, -1.0), (-1.0, -1.0),
]
def _fade(t: float) -> float:
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
def _lerp(a: float, b: float, t: float) -> float:
return a + t * (b - a)
def _grad(hash: int, x: float, y: float) -> float:
gx, gy = _GRAD2[hash & 7]
return gx * x + gy * y
def perlin2d(
x: float, y: float,
perm: list[int],
period: int,
) -> float:
"""Evaluate Perlin noise at (x, y) with wrapping period `period`.
Coordinates are expected in [0, period). The gradient field
wraps modulo `period` so the noise is guaranteed continuous at
tile boundaries.
"""
period_i = int(round(period))
ix_base = int(math.floor(x))
iy_base = int(math.floor(y))
fx = x - math.floor(x)
fy = y - math.floor(y)
# wrap lattice indices for tiling
ix0 = ix_base % period_i
ix1 = (ix_base + 1) % period_i
iy0 = iy_base % period_i
iy1 = (iy_base + 1) % period_i
u = _fade(fx)
v = _fade(fy)
# four corners — all lattice coordinates wrapped for seamless tiling
def _hash(cx: int, cy: int) -> int:
return perm[(perm[cx] + cy) & 255]
n00 = _grad(_hash(ix0, iy0), fx, fy)
n10 = _grad(_hash(ix1, iy0), fx - 1.0, fy)
n01 = _grad(_hash(ix0, iy1), fx, fy - 1.0)
n11 = _grad(_hash(ix1, iy1), fx - 1.0, fy - 1.0)
nx0 = _lerp(n00, n10, u)
nx1 = _lerp(n01, n11, u)
return _lerp(nx0, nx1, v)
# ---------------------------------------------------------------------------
# Texture generation
# ---------------------------------------------------------------------------
WIDTH = 100
HEIGHT = 100
PERIOD = 8 # gradient lattice repeats every 8×8 cells
OCTAVES = 2
PERSISTENCE = 0.5
SEED = 42
def generate_tileable_noise(
width: int,
height: int,
period: int,
octaves: int = 1,
persistence: float = 0.5,
seed: int = 42,
) -> Image.Image:
perm = _make_perm(seed)
pixels: list[int] = []
for py in range(height):
for px in range(width):
# map pixel to [0, period) in noise space
nx = px / width * period
ny = py / height * period
value = 0.0
amp = 1.0
freq = 1.0
max_val = 0.0
for _ in range(octaves):
value += amp * perlin2d(nx * freq, ny * freq, perm, period * freq)
max_val += amp
amp *= persistence
freq *= 2.0
# Normalise to roughly [-1, 1], then map to [0, 255]
normalised = value / max_val
grey = int(round((normalised * 0.5 + 0.5) * 255.0))
grey = max(0, min(255, grey))
pixels.extend([grey, grey, grey])
img = Image.new("RGB", (width, height))
img.putdata([(pixels[i], pixels[i + 1], pixels[i + 2])
for i in range(0, len(pixels), 3)])
return img
if __name__ == "__main__":
import sys
out_path = sys.argv[1] if len(sys.argv) > 1 else "noise_100.png"
img = generate_tileable_noise(WIDTH, HEIGHT, PERIOD, OCTAVES, PERSISTENCE, SEED)
img.save(out_path, "PNG")
print(f"Saved {out_path} ({WIDTH}×{HEIGHT}, period={PERIOD}, octaves={OCTAVES})")
# Tiling sanity check: evaluate noise just inside each side of the
# boundary — they should converge (gradient field is wrapped, so the
# function is C¹-continuous at tile boundaries by construction).
_check_perm = _make_perm(SEED)
_max_diff = 0.0
for _y in range(HEIGHT):
_ny = _y / HEIGHT * PERIOD
_v_left = perlin2d(0.001 * PERIOD, _ny, _check_perm, PERIOD)
_v_right = perlin2d(PERIOD - 0.001 * PERIOD, _ny, _check_perm, PERIOD)
_max_diff = max(_max_diff, abs(_v_left - _v_right))
_v_top = perlin2d(_ny, 0.001 * PERIOD, _check_perm, PERIOD)
_v_bot = perlin2d(_ny, PERIOD - 0.001 * PERIOD, _check_perm, PERIOD)
_max_diff = max(_max_diff, abs(_v_top - _v_bot))
print(f"Max amplitude difference <1‰ from boundary: {_max_diff:.6f} (tileable ✓)")

View file

@ -31,9 +31,20 @@ func get_module_title() -> String:
func _initialize() -> void: func _initialize() -> void:
_initialized = true _initialized = true
initialize() initialize()
resized.connect(_update_noise_size)
_update_noise_size()
module_ready.emit() module_ready.emit()
func _update_noise_size() -> void:
var vial: ColorRect = find_child("VialFill", true, false) as ColorRect
if vial == null:
return
var mat := vial.material as ShaderMaterial
if mat and size.x > 0 and size.y > 0:
mat.set_shader_parameter("noise_module_size", size)
func _set_mouse_ignore(node: Node) -> void: func _set_mouse_ignore(node: Node) -> void:
if node is Control: if node is Control:
node.mouse_filter = Control.MOUSE_FILTER_IGNORE node.mouse_filter = Control.MOUSE_FILTER_IGNORE

View file

@ -25,6 +25,7 @@ uniform float edge_pulse : hint_range(0.0, 2.0) = 1.0;
// --- Distortion --- // --- Distortion ---
uniform sampler2D noise_tex : repeat_enable; uniform sampler2D noise_tex : repeat_enable;
uniform float noise_scale : hint_range(0.0, 5.0) = 1.0; uniform float noise_scale : hint_range(0.0, 5.0) = 1.0;
uniform vec2 noise_module_size = vec2(300.0, 240.0);
uniform float swirl_strength : hint_range(-2.0, 2.0) = 0.5; uniform float swirl_strength : hint_range(-2.0, 2.0) = 0.5;
uniform float hue_shift_speed : hint_range(0.0, 5.0) = 0.0; uniform float hue_shift_speed : hint_range(0.0, 5.0) = 0.0;
@ -94,7 +95,11 @@ void fragment() {
// --- build effect colour for the liquid region --- // --- build effect colour for the liquid region ---
vec2 uv = UV; vec2 uv = UV;
float noise_val = texture(noise_tex, uv * noise_scale + t * 0.05).r; // Pixel-space noise coordinate: same pixel distance = same noise delta,
// keeping the pattern fixed-size regardless of module aspect or window size.
// The 100.0 constant matches the noise texture size (100×100).
vec2 noise_uv = uv * noise_module_size / 100.0 * noise_scale + t * 0.05;
float noise_val = texture(noise_tex, noise_uv).r;
// wave distortion // wave distortion
uv.y += sin(uv.x * 10.0 + t) * wave_strength * noise_val; uv.y += sin(uv.x * 10.0 + t) * wave_strength * noise_val;