feat(client): ScatterField — reusable seeded scatter for client-side paint
Extracted from the T-1194 stipple, which was the first of a family: graffiti placement, cracks in textures, drifting cloud cover — presentation decisions that must look the same when the player returns to a place, and which the simulation has no opinion about and should not be burdened with. THE LINE IT DRAWS. It answers "how is this drawn", never "what is here". A cell's biome, a settlement's position, whether a wall exists — those are world data, derived once by the server and sampled everywhere (D-255(f) mechanism B), and the player eventually stands on them; inventing those here would put the map and the ground in disagreement. Stated on the class so the next consumer does not have to re-derive it: if the answer changes what is THERE it is not a ScatterField question; if it only changes how it is DRAWN, it is. Bit-identity with the server's Rust noise is explicitly NOT a requirement (Jeroen: "a seed is a seed and the functional intended outcome is repetition here"). Nothing here is compared against a server value or round-tripped through a save, so the contract is stability across sessions, not agreement across languages — which is precisely why paint belongs on this side: it buys visual density with no cross-language determinism burden. Seeded from GameState.world_seed, so two playthroughs scatter differently and one playthrough is stable forever. API: domain() resolves a name to a salt ONCE (the first consumer runs ~700,000 times per canvas rebuild, so the hot calls take an int, never a string); value/chance/pick/jitter for discrete marks; smooth() for continuous fields like cloud cover; an optional time axis for animation. Domains keep consumers uncorrelated — without them graffiti and cracks at the same wall coordinate would mark identical spots and read as one artefact. The tests pin the CONTRACT, not the numbers — freezing outputs would make any future improvement to the mixer a breaking change for no gain. They caught a real defect immediately: (-x, -y) collided with (x, y), because negated coordinates produce negated products and the sign-bit mask folded the pair together, mirroring every mark west and south of the origin onto its north-east counterpart. Not an edge case — the descent ladder's own anchor sits at y = -5,675,959. Fixed by zigzag-encoding coordinates before mixing. 1853 client tests, 0 failed (15 new). Global capture re-verified unchanged after migrating the stipple onto the service. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
class_name ScatterField
|
||||
extends RefCounted
|
||||
|
||||
## Deterministic, seeded, position-keyed scatter for CLIENT-SIDE PAINT.
|
||||
##
|
||||
## The one job: answer "what belongs at this spot" the same way every time,
|
||||
## without asking the server. Presentation decisions that must look identical
|
||||
## when the player comes back to a place, but which the simulation has no
|
||||
## opinion about and should never be burdened with — stipple marks, graffiti
|
||||
## placement, cracks in a wall texture, drifting cloud cover.
|
||||
##
|
||||
## WHAT THIS IS NOT. It does not decide what EXISTS. A cell's biome, a
|
||||
## settlement's position, whether a wall is there at all — those are world data,
|
||||
## derived once by the server and sampled everywhere (D-255(f) mechanism B), and
|
||||
## the player will eventually stand on them. Inventing world data here would put
|
||||
## the map and the ground in disagreement. The rule that keeps the two apart:
|
||||
## **if the answer changes what is there, it is not a ScatterField question; if
|
||||
## it only changes how it is drawn, it is.**
|
||||
##
|
||||
## Bit-identity with the server's Rust noise is explicitly NOT a requirement
|
||||
## (Jeroen, 2026-08-07: *"a seed is a seed and the functional intended outcome
|
||||
## is repetition here"*). Nothing here is compared against a server value or
|
||||
## round-tripped through a save; the contract is stability across sessions, not
|
||||
## agreement across languages. That is exactly why paint belongs on this side —
|
||||
## it buys visual density with no cross-language determinism burden.
|
||||
##
|
||||
## SEEDED FROM THE WORLD. Every value mixes `GameState.world_seed`, so two
|
||||
## playthroughs scatter differently while one playthrough is stable forever.
|
||||
## A world seed of 0 (pre-connection) still works and is still deterministic —
|
||||
## it simply is not world-specific yet.
|
||||
##
|
||||
## PERFORMANCE. The first consumer runs per canvas cell — ~700,000 calls per
|
||||
## rebuild at 1290x540. So the hot calls take an int `salt`, resolved ONCE via
|
||||
## [method domain], never a string per call. Everything is integer hashing; no
|
||||
## allocation, no RNG object, no state.
|
||||
##
|
||||
## USAGE:
|
||||
## [codeblock]
|
||||
## const RELIEF := preload("res://scripts/scatter_field.gd")
|
||||
## var salt: int = RELIEF.domain(&"atlas/relief_stipple") # once
|
||||
## if RELIEF.chance(salt, col, row, 0.3): # per cell
|
||||
## draw_mark()
|
||||
## [/codeblock]
|
||||
|
||||
## Per-domain salts, resolved once per name. Domains keep independent consumers
|
||||
## UNCORRELATED: without one, graffiti and cracks seeded at the same wall
|
||||
## coordinate would mark the same spots and read as a single artefact rather
|
||||
## than two. Cached because [method domain] hashes a string and the hot path
|
||||
## must not.
|
||||
static var _domains: Dictionary = {}
|
||||
|
||||
## Mixed into every value so one playthrough is stable and two differ. Refreshed
|
||||
## from GameState lazily — see [method _seed].
|
||||
static var _world_seed: int = 0
|
||||
static var _world_seed_read: bool = false
|
||||
|
||||
|
||||
## Resolve a domain name to its salt. Call once, keep the int, pass that to the
|
||||
## hot functions. Names are free-form; the convention is `subsystem/purpose`
|
||||
## (`atlas/relief_stipple`, `world/graffiti`, `sky/cloud_cover`).
|
||||
static func domain(name: StringName) -> int:
|
||||
if _domains.has(name):
|
||||
return _domains[name]
|
||||
# FNV-1a over the name's UTF-8 — a stable string hash rather than Godot's
|
||||
# `hash()`, whose value is not contracted across engine versions and would
|
||||
# silently re-scatter every mark on an engine upgrade.
|
||||
var h: int = 0x811C9DC5
|
||||
for b: int in String(name).to_utf8_buffer():
|
||||
h = ((h ^ b) * 0x01000193) & 0xFFFFFFFF
|
||||
_domains[name] = h
|
||||
return h
|
||||
|
||||
|
||||
## Re-read the world seed on the next call — invoke when a new world loads.
|
||||
static func invalidate_world_seed() -> void:
|
||||
_world_seed_read = false
|
||||
|
||||
|
||||
static func _seed() -> int:
|
||||
if not _world_seed_read:
|
||||
# Read via the tree rather than a direct autoload reference: this file
|
||||
# carries a `class_name`, and an autoload touching a class_name symbol
|
||||
# at parse time is the documented parse-order hazard (CLAUDE.md).
|
||||
var loop := Engine.get_main_loop()
|
||||
if loop is SceneTree:
|
||||
var gs: Variant = (loop as SceneTree).root.get_node_or_null("/root/GameState")
|
||||
if gs != null:
|
||||
_world_seed = int(gs.world_seed)
|
||||
_world_seed_read = true
|
||||
return _world_seed
|
||||
|
||||
|
||||
## The core: a stable value in [0, 1) for (domain, x, y, t).
|
||||
##
|
||||
## `t` is an optional third axis — a time step for animation (cloud cover), or
|
||||
## a layer index when one consumer needs several uncorrelated fields at the
|
||||
## same position. Omit it for static paint.
|
||||
static func value(salt: int, x: int, y: int, t: int = 0) -> float:
|
||||
return float(_mix(salt, x, y, t) % 16777216) / 16777216.0
|
||||
|
||||
|
||||
## Does a mark land here? `p` is the probability in [0, 1].
|
||||
##
|
||||
## The workhorse: stipple dots, graffiti tags, crack seeds — anything that is
|
||||
## present-or-absent at a position. Uses its own value stream, so a consumer can
|
||||
## call [method chance] and [method pick] at one position without the two
|
||||
## correlating.
|
||||
static func chance(salt: int, x: int, y: int, p: float, t: int = 0) -> bool:
|
||||
return value(salt, x, y, t) < p
|
||||
|
||||
|
||||
## Choose one of `count` variants at this position — which graffiti sprite,
|
||||
## which crack pattern, which of four grass tufts. Returns 0 when `count <= 1`.
|
||||
static func pick(salt: int, x: int, y: int, count: int, t: int = 0) -> int:
|
||||
if count <= 1:
|
||||
return 0
|
||||
return int(_mix(salt ^ 0x5BF03635, x, y, t) % count)
|
||||
|
||||
|
||||
## A sub-cell offset in [-0.5, 0.5] on both axes — so scattered marks sit off
|
||||
## the lattice instead of betraying the grid they were chosen on. Two
|
||||
## independent streams, one per axis.
|
||||
static func jitter(salt: int, x: int, y: int, t: int = 0) -> Vector2:
|
||||
return Vector2(
|
||||
value(salt ^ 0x9E3779B1, x, y, t) - 0.5, value(salt ^ 0x7F4A7C15, x, y, t) - 0.5
|
||||
)
|
||||
|
||||
|
||||
## Smooth value noise in [0, 1] at world position (wx, wy) for one wavelength —
|
||||
## the continuous counterpart to [method value]'s per-cell hash.
|
||||
##
|
||||
## For anything that must read as a FIELD rather than as speckle: cloud cover,
|
||||
## damp patches on a floor, rust blooms. Animate by advancing `t`.
|
||||
static func smooth(salt: int, wx: float, wy: float, wavelength: float, t: int = 0) -> float:
|
||||
if wavelength <= 0.0:
|
||||
return value(salt, int(wx), int(wy), t)
|
||||
var fx: float = wx / wavelength
|
||||
var fy: float = wy / wavelength
|
||||
var x0: int = int(floor(fx))
|
||||
var y0: int = int(floor(fy))
|
||||
var tx: float = fx - float(x0)
|
||||
var ty: float = fy - float(y0)
|
||||
# Smoothstep so lattice cell edges are crease-free — the same shaping the
|
||||
# server's own value_noise uses, for the same reason.
|
||||
var sx: float = tx * tx * (3.0 - 2.0 * tx)
|
||||
var sy: float = ty * ty * (3.0 - 2.0 * ty)
|
||||
var c00: float = value(salt, x0, y0, t)
|
||||
var c10: float = value(salt, x0 + 1, y0, t)
|
||||
var c01: float = value(salt, x0, y0 + 1, t)
|
||||
var c11: float = value(salt, x0 + 1, y0 + 1, t)
|
||||
var a: float = c00 + (c10 - c00) * sx
|
||||
var b: float = c01 + (c11 - c01) * sx
|
||||
return a + (b - a) * sy
|
||||
|
||||
|
||||
## Integer avalanche over (world seed, salt, x, y, t). Splitmix-style finalizer:
|
||||
## every input bit reaches every output bit, so adjacent coordinates do not
|
||||
## produce adjacent values — which is the whole point, since the consumers walk
|
||||
## coordinates in order.
|
||||
static func _mix(salt: int, x: int, y: int, t: int) -> int:
|
||||
var h: int = _seed()
|
||||
h = (h ^ salt) * 0x9E3779B1
|
||||
h = (h ^ (_zig(x) * 0x85EBCA6B)) & 0x7FFFFFFFFFFFFFFF
|
||||
h = (h ^ (_zig(y) * 0xC2B2AE35)) & 0x7FFFFFFFFFFFFFFF
|
||||
if t != 0:
|
||||
h = (h ^ (_zig(t) * 0x27D4EB2F)) & 0x7FFFFFFFFFFFFFFF
|
||||
h = (h ^ (h >> 15)) * 0x2545F491
|
||||
h = (h ^ (h >> 13)) * 0x27220A95
|
||||
return absi(h ^ (h >> 16))
|
||||
|
||||
|
||||
## Zigzag-encode a signed coordinate to a distinct non-negative one
|
||||
## (0, -1, 1, -2, 2 -> 0, 1, 2, 3, 4).
|
||||
##
|
||||
## Required, not cosmetic. Without it `(-x, -y)` collided with `(x, y)`: the
|
||||
## products of negated coordinates are themselves negations, and the sign-bit
|
||||
## mask above then folded the pair together — so every mark west and south of
|
||||
## the world origin mirrored its counterpart to the north-east. World
|
||||
## coordinates are routinely negative (the descent ladder's own anchor is at
|
||||
## y = -5,675,959), so this was on the common path, not an edge case. Caught by
|
||||
## `test_negative_coordinates_are_supported`.
|
||||
static func _zig(v: int) -> int:
|
||||
return (v << 1) ^ (v >> 63)
|
||||
@@ -0,0 +1,193 @@
|
||||
## Tests for ScatterField — the client-side deterministic scatter service.
|
||||
##
|
||||
## What is worth pinning here is the CONTRACT, not the numbers: the service
|
||||
## exists so paint looks the same when the player comes back, and so that two
|
||||
## consumers scattering over the same coordinates do not draw on top of each
|
||||
## other. Both of those are properties, and both fail silently if they break —
|
||||
## a correlated domain reads as "one slightly odd texture", not as a bug.
|
||||
##
|
||||
## Deliberately NOT pinned: the specific values. Bit-identity with the server's
|
||||
## Rust noise is explicitly not a requirement (Jeroen, 2026-08-07 — "a seed is a
|
||||
## seed and the functional intended outcome is repetition here"), and freezing
|
||||
## the outputs here would turn any future improvement to the mixer into a
|
||||
## test-breaking change for no gain.
|
||||
class_name TestScatterField
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const ScatterField := preload("res://scripts/scatter_field.gd")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The core contract: same question, same answer
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_same_position_repeats_forever() -> void:
|
||||
var salt: int = ScatterField.domain(&"test/repeat")
|
||||
var first: float = ScatterField.value(salt, 12, 34)
|
||||
for _i in range(50):
|
||||
assert_float(ScatterField.value(salt, 12, 34)).override_failure_message(
|
||||
"a position must answer identically every time — this is the whole service"
|
||||
).is_equal(first)
|
||||
|
||||
|
||||
func test_neighbouring_positions_decorrelate() -> void:
|
||||
# Consumers walk coordinates in order, so adjacent inputs producing adjacent
|
||||
# outputs would render as a gradient or a moire rather than as scatter.
|
||||
var salt: int = ScatterField.domain(&"test/decorrelate")
|
||||
var a: float = ScatterField.value(salt, 100, 100)
|
||||
var b: float = ScatterField.value(salt, 101, 100)
|
||||
var c: float = ScatterField.value(salt, 100, 101)
|
||||
assert_float(absf(a - b)).is_greater(0.001)
|
||||
assert_float(absf(a - c)).is_greater(0.001)
|
||||
|
||||
|
||||
func test_values_stay_in_unit_range() -> void:
|
||||
var salt: int = ScatterField.domain(&"test/range")
|
||||
for i in range(500):
|
||||
var v: float = ScatterField.value(salt, i, i * 7)
|
||||
assert_float(v).is_between(0.0, 1.0)
|
||||
|
||||
|
||||
func test_negative_coordinates_are_supported() -> void:
|
||||
# World coordinates go negative — a consumer painting west or south of the
|
||||
# origin must not crash or fold onto its mirror position.
|
||||
var salt: int = ScatterField.domain(&"test/negative")
|
||||
var neg: float = ScatterField.value(salt, -500, -900)
|
||||
assert_float(neg).is_between(0.0, 1.0)
|
||||
assert_float(neg).override_failure_message(
|
||||
"(-x, -y) must not collide with (x, y) — that would mirror all paint about the origin"
|
||||
).is_not_equal(ScatterField.value(salt, 500, 900))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Domain separation — why graffiti and cracks do not land together
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_domains_are_uncorrelated_at_the_same_position() -> void:
|
||||
var graffiti: int = ScatterField.domain(&"world/graffiti")
|
||||
var cracks: int = ScatterField.domain(&"world/cracks")
|
||||
var agree: int = 0
|
||||
for i in range(200):
|
||||
if is_equal_approx(ScatterField.value(graffiti, i, 0), ScatterField.value(cracks, i, 0)):
|
||||
agree += 1
|
||||
assert_int(agree).override_failure_message(
|
||||
"two domains agreeing at the same coordinates means every consumer marks the "
|
||||
+ "same spots — the artefacts would stack and read as one"
|
||||
).is_less(3)
|
||||
|
||||
|
||||
func test_domain_resolution_is_stable_and_cached() -> void:
|
||||
assert_int(ScatterField.domain(&"test/stable")).is_equal(
|
||||
ScatterField.domain(&"test/stable")
|
||||
)
|
||||
assert_int(ScatterField.domain(&"test/a")).is_not_equal(ScatterField.domain(&"test/b"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The derived helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_chance_honours_its_probability() -> void:
|
||||
var salt: int = ScatterField.domain(&"test/chance")
|
||||
var hits: int = 0
|
||||
var n: int = 4000
|
||||
for i in range(n):
|
||||
if ScatterField.chance(salt, i % 64, i / 64, 0.25):
|
||||
hits += 1
|
||||
var rate: float = float(hits) / float(n)
|
||||
assert_float(rate).override_failure_message(
|
||||
"p=0.25 produced %.3f — a biased field makes every density constant a lie" % rate
|
||||
).is_between(0.21, 0.29)
|
||||
|
||||
|
||||
func test_chance_extremes_are_absolute() -> void:
|
||||
var salt: int = ScatterField.domain(&"test/extremes")
|
||||
for i in range(200):
|
||||
assert_bool(ScatterField.chance(salt, i, 0, 0.0)).is_false()
|
||||
assert_bool(ScatterField.chance(salt, i, 0, 1.0)).is_true()
|
||||
|
||||
|
||||
func test_pick_covers_its_whole_range() -> void:
|
||||
# A variant chooser that never returns some of its options silently reduces
|
||||
# the asset set — the sprite exists, it just never appears.
|
||||
var salt: int = ScatterField.domain(&"test/pick")
|
||||
var seen: Dictionary = {}
|
||||
for i in range(600):
|
||||
seen[ScatterField.pick(salt, i, 0, 4)] = true
|
||||
assert_int(seen.size()).is_equal(4)
|
||||
for i in range(50):
|
||||
assert_int(ScatterField.pick(salt, i, 0, 4)).is_between(0, 3)
|
||||
|
||||
|
||||
func test_pick_degenerate_counts_are_safe() -> void:
|
||||
var salt: int = ScatterField.domain(&"test/pick_degenerate")
|
||||
assert_int(ScatterField.pick(salt, 5, 5, 1)).is_equal(0)
|
||||
assert_int(ScatterField.pick(salt, 5, 5, 0)).is_equal(0)
|
||||
|
||||
|
||||
func test_jitter_is_centred_and_bounded() -> void:
|
||||
var salt: int = ScatterField.domain(&"test/jitter")
|
||||
var sum := Vector2.ZERO
|
||||
var n: int = 2000
|
||||
for i in range(n):
|
||||
var j: Vector2 = ScatterField.jitter(salt, i % 50, i / 50)
|
||||
assert_float(j.x).is_between(-0.5, 0.5)
|
||||
assert_float(j.y).is_between(-0.5, 0.5)
|
||||
sum += j
|
||||
# Both axes must be independent streams; a shared one puts every mark on the
|
||||
# diagonal, which reads as a hatch rather than as scatter.
|
||||
assert_float(absf(sum.x / float(n))).is_less(0.05)
|
||||
assert_float(absf(sum.y / float(n))).is_less(0.05)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The time axis — animation and layering
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_time_step_changes_the_field() -> void:
|
||||
var salt: int = ScatterField.domain(&"sky/cloud_cover")
|
||||
var t0: float = ScatterField.value(salt, 7, 7, 0)
|
||||
var t1: float = ScatterField.value(salt, 7, 7, 1)
|
||||
assert_float(absf(t0 - t1)).override_failure_message(
|
||||
"advancing t must move the field, or cloud cover cannot animate"
|
||||
).is_greater(0.001)
|
||||
# ...and each step is itself stable, so an animation loops rather than boils.
|
||||
assert_float(ScatterField.value(salt, 7, 7, 1)).is_equal(t1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# smooth() — the continuous field
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_smooth_is_continuous_between_lattice_points() -> void:
|
||||
# The point of smooth() over value(): neighbouring samples must be CLOSE, or
|
||||
# cloud cover is speckle instead of cloud.
|
||||
var salt: int = ScatterField.domain(&"test/smooth")
|
||||
var prev: float = ScatterField.smooth(salt, 0.0, 0.0, 64.0)
|
||||
for i in range(1, 200):
|
||||
var v: float = ScatterField.smooth(salt, float(i), 0.0, 64.0)
|
||||
assert_float(absf(v - prev)).override_failure_message(
|
||||
"smooth() jumped %.3f in one world unit at wavelength 64 — not a field" % absf(v - prev)
|
||||
).is_less(0.2)
|
||||
prev = v
|
||||
|
||||
|
||||
func test_smooth_stays_in_unit_range_and_repeats() -> void:
|
||||
var salt: int = ScatterField.domain(&"test/smooth_range")
|
||||
for i in range(300):
|
||||
var v: float = ScatterField.smooth(salt, float(i) * 3.7, float(i) * 1.3, 32.0)
|
||||
assert_float(v).is_between(0.0, 1.0)
|
||||
assert_float(ScatterField.smooth(salt, 12.5, 8.25, 32.0)).is_equal(
|
||||
ScatterField.smooth(salt, 12.5, 8.25, 32.0)
|
||||
)
|
||||
|
||||
|
||||
func test_smooth_degenerate_wavelength_does_not_divide_by_zero() -> void:
|
||||
var salt: int = ScatterField.domain(&"test/smooth_zero")
|
||||
assert_float(ScatterField.smooth(salt, 4.0, 4.0, 0.0)).is_between(0.0, 1.0)
|
||||
assert_float(ScatterField.smooth(salt, 4.0, 4.0, -8.0)).is_between(0.0, 1.0)
|
||||
@@ -25,6 +25,12 @@ extends RefCounted
|
||||
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
|
||||
## Preloaded rather than referenced by its `class_name`, matching this file's
|
||||
## existing convention — a global class symbol is not guaranteed to be
|
||||
## registered when a script is opened directly (the parse sweep does exactly
|
||||
## that), whereas a preload resolves by path every time.
|
||||
const ScatterField := preload("res://scripts/scatter_field.gd")
|
||||
|
||||
const TOGGLE_TEMP: String = "gen_dw_temp"
|
||||
const TOGGLE_MOISTURE: String = "gen_dw_moisture"
|
||||
const TOGGLE_VEGETATION: String = "gen_dw_veg"
|
||||
@@ -162,14 +168,17 @@ static func _texture(planes: CellPlanes, col: int, row: int, base: Color) -> Col
|
||||
var out: Color = base
|
||||
|
||||
var rug: float = _ruggedness(planes, col, row)
|
||||
if rug > 0.0 and _dither(col, row, 0x51F) < rug:
|
||||
if rug > 0.0 and ScatterField.chance(_relief_salt, col, row, rug):
|
||||
out = out.darkened(RELIEF_STIPPLE_STRENGTH * rug)
|
||||
|
||||
# Half-frequency lattice: one mark per 2x2 cells, so the vegetation grain is
|
||||
# visibly coarser than the relief grain rather than a second speckle at the
|
||||
# same pitch.
|
||||
var veg: int = _l8_value(planes.vegetation, col, row)
|
||||
if veg >= VEGETATION_SCRUB and _dither(col >> 1, row >> 1, 0xB33F) < VEGETATION_PATCH_DENSITY:
|
||||
if (
|
||||
veg >= VEGETATION_SCRUB
|
||||
and ScatterField.chance(_veg_salt, col >> 1, row >> 1, VEGETATION_PATCH_DENSITY)
|
||||
):
|
||||
out = out.darkened(VEGETATION_PATCH_STRENGTH)
|
||||
return out
|
||||
|
||||
@@ -217,14 +226,13 @@ static func _ruggedness(planes: CellPlanes, col: int, row: int) -> float:
|
||||
return clampf(maxf(from_relief, from_elev), 0.0, 1.0)
|
||||
|
||||
|
||||
## Deterministic [0,1) dither for a cell, salted per mark type so the relief and
|
||||
## vegetation lattices never correlate (sharing a hash would stack both marks on
|
||||
## the same cells and read as one texture at double contrast).
|
||||
static func _dither(col: int, row: int, salt: int) -> float:
|
||||
var h: int = (col * 0x1F1F1F1F) ^ (row * 0x9E3779B1) ^ salt
|
||||
h = (h ^ (h >> 15)) * 0x2545F491
|
||||
h = (h ^ (h >> 13)) * 0x27220A95
|
||||
return float(absi(h) % 4096) / 4096.0
|
||||
## Domain salts for the two marks, resolved ONCE at class load rather than per
|
||||
## pixel (this file's colorize loop runs ~700,000 times per canvas rebuild at
|
||||
## 1290x540). Separate domains keep the two lattices uncorrelated — sharing one
|
||||
## would stack both marks on the same cells and read as a single texture at
|
||||
## double contrast.
|
||||
static var _relief_salt: int = ScatterField.domain(&"atlas/relief_stipple")
|
||||
static var _veg_salt: int = ScatterField.domain(&"atlas/vegetation_patch")
|
||||
|
||||
|
||||
static func _base_color(planes: CellPlanes, col: int, row: int) -> Color:
|
||||
|
||||
Reference in New Issue
Block a user