Merge origin/main (phasor-field-rename a085c0b3) into hands/cultmath-glsl-build-dxc

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-17 21:32:08 +02:00
commit 7c47cbaffd
45 changed files with 2385 additions and 534 deletions

View file

@ -9,7 +9,7 @@ the Self opened the `cultmath-tapes` campaign from pass 2; pass 3 revises the
two CultLib cuts after their first Hands reports and the operator's challenge
to the march, and changes the site field to envelope-then-noise; pass 4
(section "Pass 4") revises `interval-ops` to r3 after its second Hands report
and Soul's verdict, and names the one shipped void (Q9). Pass 5 (section "Pass 5") maps
and Soul's verdict, and names the one shipped void (Q9, ruled `operator-shipped-void`). Pass 5 (section "Pass 5") maps
affine forms against CultLib `main` at `a7966142`. Where a later pass and
an earlier section disagree, the later pass wins. Where this map and the Body
disagree, the Body wins and this map is stale.
@ -126,9 +126,10 @@ nebula input tokens and the seed values of the ground tokens.
**Not consumers.** Asura (its field is noise-dominated and needs no tape).
AetheriaEve and AetheriaThing (archived). The material graph line until it returns
after the demo (`aetheria-release:ruling:demo-material-scope`). The brush-grammar
station, until pruning and meshing unpark. Whether Aetheria's nebula moves onto the
span framework is not decided; until it is, the nebula consumes CultMath noise at
its pin bump and nothing else from this line.
station, until pruning and meshing unpark. Aetheria's nebula on the
span framework is an option to try (ruling `aetheria-nebula-spans-as-option`), not
yet a consumer; until a cut makes it one, the nebula consumes CultMath noise at its
pin bump and nothing else from this line.
**Invariants.**
@ -750,7 +751,7 @@ and `S` from the exponent (`e = 4` gives `S ≈ 1.3 Rh`, `e = 0.25` gives
`S ≈ 0.12 Rh`), so `(Rh, S)` are the knobs and `(Rc, e)` derived. Shipped:
`Rh = 198`, `S = 50`, so `Rc = Rh + S = 248` and `e = −ln CARVE / ln(1 −
(Rh/Rc)²) = 0.40`, `K = 1/30` (pass 4 corrects this line's `e ≈ 0.6, Rc ≈
220`, which did not satisfy the formula; Q9 holds the look question).
220`, which did not satisfy the formula; Q9 asked the look question and is ruled `operator-shipped-void`).
Imagination's model (scratchpad `cavity_model.py`; the test confirms or
refutes), 1080p, `N = 8`, per pixel for a converged frame; "ref" is the
@ -964,7 +965,7 @@ answers the first; the second is content and stays.
| --- | --- | --- |
| `frustum-ball-float-crossing` | Medium | the radius is widened by a derived float32 bound (below); seven extreme families join the enclosure test; the strictness sentence leaves the C#, the HLSL and `design.md` |
| `aetheria-far-plane-miscited` | Low | `far = 2048`, the Main Camera's (`ARPG.unity:31777`, `:31830`); the Tint camera's 1000 was the wrong camera |
| `shipped-void-two-scenes` | Medium | one shipped void, `(Rh, S) = (198, 50)`, `(Rc, e) = (248, 0.40)` derived; the map, the site-ground spec and the test agree; the look question is Q9 |
| `shipped-void-two-scenes` | Medium | one shipped void, `(Rh, S) = (198, 50)`, `(Rc, e) = (248, 0.40)` derived; the map, the site-ground spec and the test agree; the look question was Q9, ruled `operator-shipped-void` |
| `lod-weights-never-exercised` | Low | configuration "LOD far" (`Rh = 4000`, `S = 50`, low camera, `f = 467`) reaches weights in `(0, 1)` and at 0; the `[w(z1), 1]` bound is recorded as a deviation with its soundness argument |
| `stryker-timeout-survivor` | Low | 2525 named equivalent; ball radii to `1e3` so `L/r` dies to an enclosure test; every timeout hand-run; the report claims only demonstrated kills |
@ -1011,7 +1012,7 @@ analytic body the second gains nothing: across a 442-wide ramp the optical
depth exceeds 4 before the density is provably constant, so rays end inside the
ramp and the body is never proven. The map's own rule says `(Rh, S)` are the
knobs, so the honest choice is the first; it changes the look relative to the
site section, hence Q9 (below). For `CARVE = 1.5` the ramp width is tied to the
site section, hence Q9 (below; ruled (a), `operator-shipped-void`). For `CARVE = 1.5` the ramp width is tied to the
exponent (`S/Rh = 1/√(1 − 1.5^(−1/e)) − 1`: `e = 0.4 → 0.25`, `e = 0.7 →
0.5`, `e = 1 → 0.73`, `e = 4 → 2.2`), so "pseudo-Gaussian" and "narrow" cannot
both be had without a third knob, and a narrow wall at `e = 4` needs
@ -1887,9 +1888,9 @@ Aetheria twin.
- `lattice-site-weight-source`: how a weighted lattice node gets a per-site
weight (T7c). Re-asked as `lattice-weight-from-bodies` and ruled
`lattice-weight-from-bodies-ruled` (node-reads-bodies).
- `lattice-window-sound-or-measured` (open, from section
"lattice-weighted-cellular"): a proven ring search or a fixed measured 7x7
window. Gates line 4a's search loop.
- `lattice-window-sound-or-measured` (from section "lattice-weighted-cellular"):
a proven ring search or a fixed measured 7x7 window. Ruled
`lattice-ring-exit-sound` (ring by ring until no farther cell can win).
Prior art in hand: `F:\Projects\aetheria-material-path-prior-art.md` section 4
(GPU interpreters: Fidget's crates, Dolphin ubershaders, Laine et al. on
@ -1899,8 +1900,7 @@ found) and section 6 (IR placement); Aetheria
filaments). Neither covers a GPU tape interpreter's register storage (dynamic
indexing of local arrays and scratch spill, groupshared memory, Fidget-wgpu's
`tape_interpreter.wgsl`, Keeter's MPR 2020) or per-site weight fields in
lattice Voronoi. Both questions lack that prior art; Self runs Eyes before
putting them.
lattice Voronoi. Both questions have since been ruled.
### Cut sequence (Pass 7; one line each, mapped when about to run)
@ -2340,6 +2340,65 @@ and 16:50 UTC (18:30 to 18:50 CEST). Logs are in the Imagination scratchpad
the committed GLSL is 80, 9, 10, 18. `--relax-nan-checks` changes no lavapipe bit. So the
cleaned route is closer to the HLSL than today's GLSL is, and "bit-identical to today's
lowering" cannot be its gate. With apt's spirv-tools 2025.1~rc1 the figures for this route are the same.
- **Why `span_fbm` and `span_ridged` differ by 1 ulp (Pass 9c).** Imagination `imagination-ag4-span-ulp`,
2026-10-17, on 23ac4e89, same image and tools as `cut-glsl-build-dxc.h1`, probes in the session
scratchpad `logs/ag4-ulp/` (`probe1.sh` to `probe4.sh`). Only the curvature word (word 4 of 6) differs, in
41 and 42 of 1536 words. Two causes, both measured:
- **dxc's entry-point optimiser reassociates.** `cultmath_snoise_hessian` ends every entry in
`105.0 * (...)`. In the `-O3` entry module the 105 is hoisted out: nine squares are summed bare and
the sum is multiplied once (`OpFMul %11025 %sum`, then `Sqrt`), where the source squares `105 * a`
nine times. That is real arithmetic, not float arithmetic. Neither `NoContraction` nor
`--relax-nan-checks` touches it. `dxc -Gis` (IEEE strictness) stops it.
- **The driver contracts mul+add by use count.** lavapipe (NIR) fuses an `OpFMul` into the `OpFAdd`
that consumes it only when the product has one use. dxc's CSE shares the symmetric Hessian's
off-diagonal squares (two uses each), so the entry module fuses some of the nine pairs; the `-Od`
library module has nine single-use products and fuses all nine. The `OpFAdd` order is the same in
both (left fold, as the C# `span_frobenius`).
- Decorating every `OpFMul`, `OpFAdd`, `OpFSub`, `OpVectorTimesScalar` and `OpDot` `NoContraction` in
both final modules, f103 and f104: `-Od` HLSL equals the built GLSL (0 words differ); `-O3`
HLSL still differs in 31 and 44 (the hoist). `-Gis` (with `-O3`) plus `NoContraction` equals the built GLSL on
127 of 127 families, and so does `-Od` plus `NoContraction`. Plain `-Od` HLSL differs from the built GLSL in 664 words
(contraction depends on use structure), so `-Od` alone is worse than `-O3`: your suspicion of `-Od`
on the HLSL route is not a fix.
- **Which is right.** Against the C# fixture, exact cases of 256 for f103 are 3 (HLSL) and 4 (GLSL) plain,
and 151 for both under `NoContraction`; for f104, 7 and 10 plain, 141 under it. Of the first 60 families whose
count moved, the strict form is nearer the C# in all but one, which moved by 1. Strict IEEE, no contraction, no
reassociation is what C# computes, so it is "right"; the plain routes are each one driver's
fusion pattern. The built GLSL is source-faithful (nothing in the `-Od` route reassociates).
- **What makes them agree.** The equality gate (`GLSL_DIR` mode) compares both routes in the strict
form: HLSL entry built with `-Gis`, and a harness pass that adds `NoContraction` to every float op of
both modules (about 12 lines of `spirv-dis` and `spirv-as`; `tools/device/nc.py` in the probe). That is
127 of 127. The shipped GLSL and the C# bounds judge are unchanged. See question `glsl-gate-strict-form`.
- **The build at main, with the rename and the strict form (Pass 9d).** Imagination `imagination-ag5-glsl`,
2026-10-17, scratch worktree `C:\cl-wt\imag-ag5-probe` (not pushed): `23ac4e89` merged with origin/main
`86cd84a3` as `69987d91` (no conflict; README.md and tools/device/gen.py auto-merged), probe commits
`575badb6` to `d14bfbbe` (`probe/p1.sh`, `p2.sh`, `spec.py`). Yggdrasil, `mcr.microsoft.com/dotnet/sdk:10.0` and
`ubuntu:24.04`; logs in the session scratchpad `imagination-ag5-glsl/p2.log` to `p4.log`.
- **Site arrays break the wrappers.** `wrappers.py` passes `sites[CULTMATH_MAX_SITES]` as the argument (its
last-token rule), and dxc refuses the three wrappers of `cultmath_power_border`,
`cultmath_lattice_cell_distance` and `cultmath_weighted_lattice_cells` ('no known conversion from float4 to
float4 [128]'). Stripping the `[..]` suffix from the argument fixes it; then the build is green: core 139
functions over 91 names (1554 lines, 54775 bytes), cycles 51/51 (867, 21250), phacelle 1/1 (82, 3199); zero
suffixed ids and zero inout.
- **The renamed fields survive SPIRV-Cross.** With `float4 cosine; float4 sine;` in the HLSL (HLSL only, in
the job), the built phacelle.glsl declares `vec4 cosine; vec4 sine;`.
- **The strict-form gate holds at main.** With `-Gis` on the HLSL entry and NoContraction on both modules
(the text `nc.py` of Pass 9c, probe only): lavapipe-glsl equals lavapipe-hlsl on 129 of 129 work items.
The work list is 125 fixture families (123 at `774bebb0`; the lattice loop added two) plus one
reference item for each of the 4 sampled affine families, so h1's 127 was 123 + 4.
- **The built GLSL loses the capacity override.** dxc preprocesses `CULTMATH_MAX_SITES`, so built core.glsl
has no mention of it and four literal `[128]` arrays. A consumer with `#define CULTMATH_MAX_SITES 4`
passing `vec4 s[4]` fails glslang ('no matching overloaded function'); the same consumer compiles
against the committed (lowered) GLSL and through dxc against the HLSL.
- **dxc has no spec-constant array size.** `[[vk::constant_id(7)]] const int CAP = 128;` as an array
parameter's length is refused ('variable length arrays are not supported in HLSL').
- **A SPIR-V spec constant carries it.** In the built SPIR-V exactly one constant is an array length
equal to 128 (id 1297), used by nothing but OpTypeArray. Made an OpSpecConstant with SpecId 0, it passes
spirv-val, and SPIRV-Cross ES emits `#ifndef SPIRV_CROSS_CONSTANT_ID_0` / `#define ... 128u` /
`const uint _1297 = SPIRV_CROSS_CONSTANT_ID_0;` with `vec4 sites[_1297]` in every site-array
signature. The probe's consumer defined the macro as a plain int and was refused for the uint
initializer, so the `uint(CULTMATH_MAX_SITES)` mapping that `cut-glsl-build-dxc.r2` writes is
the route, but has not been compiled yet.
- **Capability strip.** `spirv-opt --trim-capabilities` (spirv-tools 2025.1~rc1) leaves all 14
capabilities and both extensions in place, and SPIRV-Cross still refuses ("RayQuery requires Vulkan
GLSL 460"). Stripping every capability except Shader and Linkage and every OpExtension in the
@ -2407,11 +2466,23 @@ and 16:50 UTC (18:30 to 18:50 CEST). Logs are in the Imagination scratchpad
lavapipe-hlsl result bit for bit in every family (117 of 117 measured), and the C# judge's
bounds are re-recorded only where a family moved and equals its HLSL result.
- **CultPhasor's JSON keys (model page row).** Named by `CultMathJson`; written as `cosine`/`sine` from
`phasor-field-rename` on, and read as either those or the legacy `cos`/`sin`, forever unless a later
Breaking change removes the legacy names. Decided by ruling `phasor-fields-renamed-at-source`. The
MessagePack wire is positional and never named the fields, so `.cc` documents are untouched both ways.
A reader from before the rename skips the new keys and decodes zeros (its unknown-property rule), which
is why the change is Breaking in both changelogs: upgrade readers before writers.
- **The capacity through the build.** dxc cannot keep a macro and refuses spec-constant array sizes, so
the build turns the one array-length constant into a SPIR-V spec constant and `partition.py` maps
`CULTMATH_MAX_SITES` onto SPIRV-Cross's `SPIRV_CROSS_CONSTANT_ID_0` with source-copied header lines.
Rejected: building the GLSL per capacity (a consumer would run the build to change one number), and
rewriting `[128]` in the emitted text (the forbidden text post-pass). Pass 9d.
### Cut sequence (Pass 9; one line each, mapped when about to run)
1. `shader-conforming-run`: spec admitted. The lavapipe job with the C# device judge, run against
today's committed HLSL and GLSL, so the next cut is verified against a running check.
2. `glsl-build-dxc` (spec admitted): the build, not yet the switch. `packages/cultmath/tools/glsl/`
2. `glsl-build-dxc` (spec `cut-glsl-build-dxc.r2` admitted; r1's h1 left the gate red, Pass 9d): the build, not yet the switch. r2 merges main, carries site arrays and their capacity (a spec constant mapped to `CULTMATH_MAX_SITES`), the strict-form gate and the renamed phasor fields, and waits on `phasor-field-rename` (spec admitted: `cosine`/`sine` in C#, HLSL, GLSL and CultMathJson, legacy keys read; CultMath's next release is 0.5.0, a release cut of its own). `packages/cultmath/tools/glsl/`
holds `build-glsl.sh`, which pins dxc v1.9.2607 (the sha above), SPIRV-Cross and SPIRV-Tools
`vulkan-sdk-1.4.363.0`, plus one SPIR-V tool in Python. The build generates the export wrappers into
its temp directory (never committed), compiles source plus wrappers with `-T lib_6_3 -spirv -Od`,
@ -2432,3 +2503,23 @@ and 16:50 UTC (18:30 to 18:50 CEST). Logs are in the Imagination scratchpad
It settles `span-allowance-gpu-margin`'s six CPU survivors, `iv-exp-gpu-widening`'s measurement and
`device-p2-pin-notes`, and closes `glsl-browser-parity-run` and `affine-device-runner-seed`. It waits
on 2b, because a capture of the old lowering goes stale at the switch.
### shader-lattice-loop (follow-ups `shader-weighted-lattice-loop`, `lattice-shader-pins`)
Written by Imagination (agent `imagination-ag4-shader-loop`, session `self-2026-10-16-ag4`) against `origin/main`
`e2051c5a` and the build branch at `23ac4e89`. Research and probes: `docs/research/shader-variable-inputs.md`.
Spec: `cultmath-tapes:cut_spec:cut-shader-lattice-loop.r1`. Question: `shader-site-capacity` (gates the spec).
- **Bodies reach the shader as `float4 sites[CULTMATH_MAX_SITES]` plus `int count`, by value.** A caller accessor
macro cannot survive the library build (the built GLSL has no consumer macros), and library-owned uniforms would
take a binding the consumer owns. dxc, the SPIR-V edit, SPIRV-Cross and a WebGL2-style uniform-array consumer all
accept the array form (probes P1-P3).
- **The shader owns the whole search.** `cultmath_weighted_lattice_cells` and `cultmath_power_border` replace the two
comment recipes; the four steps stay public. The box pass recomputes distances (no shader cache), the ring loop is
bounded by 37 with C#'s exit.
- **The lavapipe run kills the exit and the normaliser** (probe table, research section 3): M1-M6 fail the C# device
judge; M7, a late exit, is equivalent by the window-independence proof. M1 needs several far-ring points.
- **Smooth-step cap point** joins its family (m = f1 + smoothing, d past it, smoothing >= 0.5).
- **Yggdrasil's sdk:10.0 Ubuntu image owns `glsl-parity.json`'s platform values** with the lavapipe bounds; a write
on another OS is refused.
- Left: `shader-span-functions-array-form` (power_cells, multiplicative_cells, soft_gabriel_gate array forms).

View file

@ -108,15 +108,18 @@ source.
- Asura does not need this campaign. Its field is noise-dominated, and its
`A_max` shell and band-limiting already give the cheap culling.
## Open before unparking
## Decided since the unpark, and what stays open
- Run the (a) spike first, or go straight to (c)?
- Pick the interval representation (plain intervals or affine arithmetic) and
the noise bound strategy.
Affine forms for noise and warp were unparked on 2026-10-03 (ruling
`cultmath-tapes:ruling:affine-forms-unparked`): one shared symbol, mapped in
`docs/cultmath-interval-ground-cut.md`, "Pass 5".
- Choose the bytecode register model for the HLSL interpreter, given GPU
register pressure.
- Decide whether the brush-grammar station project lives in Aetheria or in
its own repo.
- The spike-or-(c) choice was settled by the operator's unpark: the tape
unparked for Aetheria's trophic species graphs (ruling
`cultmath-tapes:ruling:tape-unparked-for-trophic-graphs`).
- Interval representation: affine forms for noise and warp, unparked on
2026-10-03 (ruling `cultmath-tapes:ruling:affine-forms-unparked`): one
shared symbol, mapped in `docs/cultmath-interval-ground-cut.md`, "Pass 5".
- Register model: the HLSL interpreter keeps its registers in groupshared
memory, a slice per thread, linear-scan allocated at lowering (ruling
`cultmath-tapes:ruling:tape-register-model-ruled`; research in
`docs/research/tape-register-model.md`).
- Open: whether the brush-grammar station project lives in Aetheria or in
its own repo (follow-up `cultmath-tapes:follow_up:brush-grammar-station-repo`,
raised as an operator question when a station cut reaches the frontier).

View file

@ -0,0 +1,11 @@
import re,sys
src,dst=sys.argv[1:3]
L=open(src).read().split('\n')
ids=[]
for l in L:
m=re.match(r'\s*(%\w+) = Op(FMul|FAdd|FSub|VectorTimesScalar|Dot)\b',l)
if m: ids.append(m.group(1))
i=next(k for k,l in enumerate(L) if re.search(r'= OpType',l))
L[i:i]=[' OpDecorate %s NoContraction'%x for x in ids]
open(dst,'w').write('\n'.join(L))
print(src,len(ids))

View file

@ -0,0 +1,19 @@
set -e
apt-get update -qq >/dev/null; apt-get install -y -qq --no-install-recommends python3 spirv-tools >/dev/null 2>&1
P=packages/cultmath/tools
python3 - <<'PY'
p='packages/cultmath/tools/device/run-lavapipe.sh'
s=open(p).read()
k='python3 "$DEV/gen.py" "$W/work" "$CM"\n'
s=s.replace(k,k+'sed -i -E "s/[.](cos|sin)([^A-Za-z0-9_])/._\1\2/g" "$W"/work/*.comp\ngrep -E "^f10[34]\t" "$W/work/manifest.txt" >"$W/work/m2"; mv "$W/work/m2" "$W/work/manifest.txt"\n')
open(p,'w').write(s)
PY
bash $P/glsl/build-glsl.sh /tmp/glslout >/dev/null
GLSL_DIR=/tmp/glslout bash $P/device/run-lavapipe.sh 2>&1 | tail -4 || true
W=/tmp/device/work
for t in f103 f104; do for r in h g; do
spirv-dis --raw-id $W/$t.$r.spv -o /tmp/$t.$r.dis
echo "== $t $r: NoContraction $(grep -c NoContraction /tmp/$t.$r.dis) Fma $(grep -c ' Fma ' /tmp/$t.$r.dis) FMul $(grep -c OpFMul /tmp/$t.$r.dis) FAdd $(grep -c OpFAdd /tmp/$t.$r.dis) FSub $(grep -c OpFSub /tmp/$t.$r.dis) lines $(wc -l </tmp/$t.$r.dis)"
done; done
mkdir -p /tmp/dump; cp /tmp/f10?.?.dis /tmp/dump/; cp $W/f103.hlsl $W/f103.comp /tmp/dump/; cp /tmp/glslout/*.glsl /tmp/dump/
echo DUMP-BEGIN; tar czf - -C /tmp dump | base64 -w0; echo; echo DUMP-END

View file

@ -0,0 +1,46 @@
set -e
apt-get update -qq >/dev/null; apt-get install -y -qq --no-install-recommends python3 spirv-tools >/dev/null 2>&1
cat >/tmp/nc.py <<'PYEOF'
import re,sys
src,dst=sys.argv[1:3]
L=open(src).read().split('\n')
ids=[]
for l in L:
m=re.match(r'\s*(%\w+) = Op(FMul|FAdd|FSub|VectorTimesScalar|Dot)\b',l)
if m: ids.append(m.group(1))
i=next(k for k,l in enumerate(L) if re.search(r'= OpType',l))
L[i:i]=[' OpDecorate %s NoContraction'%x for x in ids]
open(dst,'w').write('\n'.join(L))
print(src,len(ids))
PYEOF
P=packages/cultmath/tools
python3 - <<'PY'
p='packages/cultmath/tools/device/run-lavapipe.sh'
s=open(p).read()
k='python3 "$DEV/gen.py" "$W/work" "$CM"\n'
s=s.replace(k,k+'sed -i -E "s/[.](cos|sin)([^A-Za-z0-9_])/._\1\2/g" "$W"/work/*.comp\ngrep -E "^f10[34]\t" "$W/work/manifest.txt" >"$W/work/m2"; mv "$W/work/m2" "$W/work/manifest.txt"\n')
open(p,'w').write(s)
PY
bash $P/glsl/build-glsl.sh /tmp/glslout >/dev/null
GLSL_DIR=/tmp/glslout bash $P/device/run-lavapipe.sh 2>&1 | tail -4 || true
W=/tmp/device/work; R=/tmp/device
mkdir -p $R/nc/lavapipe-hlsl $R/nc/lavapipe-glsl
while IFS=$'\t' read -r tag _ _ n _ wout; do
for r in h g; do
spirv-dis --raw-id $W/$tag.$r.spv -o /tmp/$tag.$r.dis
python3 /tmp/nc.py /tmp/$tag.$r.dis /tmp/$tag.$r.nc.dis
spirv-as --target-env vulkan1.2 /tmp/$tag.$r.nc.dis -o $W/$tag.$r.nc.spv
spirv-val --target-env vulkan1.2 $W/$tag.$r.nc.spv
d=hlsl; [ $r = g ] && d=glsl
$R/vkrun $W/$tag.$r.nc.spv $W/$tag.in $R/nc/lavapipe-$d/$tag.out $n $wout >/dev/null
done
echo "$tag nc: hlsl==glsl $(cmp -s $R/nc/lavapipe-hlsl/$tag.out $R/nc/lavapipe-glsl/$tag.out && echo YES || echo NO); nc-hlsl==plain-hlsl $(cmp -s $R/nc/lavapipe-hlsl/$tag.out $R/results/lavapipe-hlsl/$tag.out && echo YES || echo NO); nc-glsl==plain-glsl $(cmp -s $R/nc/lavapipe-glsl/$tag.out $R/results/lavapipe-glsl/$tag.out && echo YES || echo NO)"
done <$W/manifest.txt
cp /tmp/device/vkrun /tmp/ 2>/dev/null || true
export CULTMATH_DEVICE_NAME=$(sort -u $R/device.txt | head -1 | sed 's/^device=//') CULTMATH_DEVICE_IDENTITY=x CULTMATH_WRITE_DEVICE_BOUNDS=1
for v in results nc; do
export CULTMATH_DEVICE_RESULTS=$R/$v
dotnet test packages/cultmath/tests/CultMath.Tests --no-build --filter "FullyQualifiedName~GlslMirrorTests.DeviceResultsHoldFixture" >/tmp/judge.$v.log 2>&1 || true
echo "== judge $v"; grep -E "^(lavapipe-)" packages/cultmath/tests/CultMath.Tests/fixtures/device-bounds-lavapipe.txt | grep -E " 10[34] " | cut -f1,2,5,6 || tail -5 /tmp/judge.$v.log
tail -3 /tmp/judge.$v.log
done

View file

@ -0,0 +1,53 @@
set -e
apt-get update -qq >/dev/null; apt-get install -y -qq --no-install-recommends python3 spirv-tools >/dev/null 2>&1
cat >/tmp/nc.py <<'PYEOF'
import re,sys
src,dst=sys.argv[1:3]
L=open(src).read().split('\n')
ids=[]
for l in L:
m=re.match(r'\s*(%\w+) = Op(FMul|FAdd|FSub|VectorTimesScalar|Dot)\b',l)
if m: ids.append(m.group(1))
i=next(k for k,l in enumerate(L) if re.search(r'= OpType',l))
L[i:i]=[' OpDecorate %s NoContraction'%x for x in ids]
open(dst,'w').write('\n'.join(L))
print(src,len(ids))
PYEOF
P=packages/cultmath/tools
python3 - <<'PY'
p='packages/cultmath/tools/device/run-lavapipe.sh'
s=open(p).read()
k='python3 "$DEV/gen.py" "$W/work" "$CM"\n'
s=s.replace(k,k+'sed -i -E "s/[.](cos|sin)([^A-Za-z0-9_])/._\1\2/g" "$W"/work/*.comp\ngrep -E "^f10[34]\t" "$W/work/manifest.txt" >"$W/work/m2"; mv "$W/work/m2" "$W/work/manifest.txt"\n')
open(p,'w').write(s)
PY
bash $P/glsl/build-glsl.sh /tmp/glslout >/dev/null
GLSL_DIR=/tmp/glslout bash $P/device/run-lavapipe.sh 2>&1 | tail -4 || true
W=/tmp/device/work; R=/tmp/device
DXC=$(find $R/dxc -name dxc -type f | head -1); export LD_LIBRARY_PATH=$(dirname $DXC)/../lib:$(dirname $DXC)
CM=packages/cultmath
cat >/tmp/cnt.py <<'PYEOF'
import sys,struct
a=open(sys.argv[1],'rb').read();b=open(sys.argv[2],'rb').read()
n=len(a)//4;x=struct.unpack('<%dI'%n,a);y=struct.unpack('<%dI'%n,b)
d=[i for i in range(n) if x[i]!=y[i]]
print(len(d),'of',n,'cols',sorted(set(i%6 for i in d)))
PYEOF
run() { # name spv tag n wout
$R/vkrun $2 $W/$3.in /tmp/o.$1.$3 $4 $5 >/dev/null; }
OPT="--target-env=spv1.5 --eliminate-local-single-store --eliminate-dead-code-aggressive"
while IFS=$' ' read -r tag _ _ n _ wout; do
for r in h g; do spirv-dis --raw-id $W/$tag.$r.spv -o /tmp/$tag.$r.dis; python3 /tmp/nc.py /tmp/$tag.$r.dis /tmp/$tag.$r.nc.dis >/dev/null; spirv-as --target-env vulkan1.2 /tmp/$tag.$r.nc.dis -o $W/$tag.$r.nc.spv; done
# h variants
$DXC -T cs_6_0 -E main -HV 2021 -spirv -fspv-target-env=vulkan1.2 -Od -I $CM/shaders $W/$tag.hlsl -Fo $W/$tag.hOd.spv >/dev/null 2>&1
spirv-opt $OPT $W/$tag.hOd.spv -o $W/$tag.hOdO.spv
for v in hOd hOdO; do spirv-dis --raw-id $W/$tag.$v.spv -o /tmp/$tag.$v.dis; python3 /tmp/nc.py /tmp/$tag.$v.dis /tmp/$tag.$v.nc.dis >/dev/null; spirv-as --target-env vulkan1.2 /tmp/$tag.$v.nc.dis -o $W/$tag.$v.nc.spv; done
for v in h g hOd hOdO; do run $v $W/$tag.$v.spv $tag $n $wout; run $v.nc $W/$tag.$v.nc.spv $tag $n $wout; done
for v in h hOd hOdO; do
echo "$tag $v vs g: $(python3 /tmp/cnt.py /tmp/o.$v.$tag /tmp/o.g.$tag)"
echo "$tag $v.nc vs g.nc: $(python3 /tmp/cnt.py /tmp/o.$v.nc.$tag /tmp/o.g.nc.$tag)"
done
echo "$tag h.nc vs h: $(python3 /tmp/cnt.py /tmp/o.h.nc.$tag /tmp/o.h.$tag)"
echo "$tag g.nc vs g: $(python3 /tmp/cnt.py /tmp/o.g.nc.$tag /tmp/o.g.$tag)"
echo "$tag hOd.nc vs hOd: $(python3 /tmp/cnt.py /tmp/o.hOd.nc.$tag /tmp/o.hOd.$tag)"
done <$W/manifest.txt

View file

@ -0,0 +1,53 @@
set -e
apt-get update -qq >/dev/null; apt-get install -y -qq --no-install-recommends python3 spirv-tools >/dev/null 2>&1
cat >/tmp/nc.py <<'PYEOF'
import re,sys
src,dst=sys.argv[1:3]
L=open(src).read().split('\n')
ids=[]
for l in L:
m=re.match(r'\s*(%\w+) = Op(FMul|FAdd|FSub|VectorTimesScalar|Dot)\b',l)
if m: ids.append(m.group(1))
i=next(k for k,l in enumerate(L) if re.search(r'= OpType',l))
L[i:i]=[' OpDecorate %s NoContraction'%x for x in ids]
open(dst,'w').write('\n'.join(L))
print(src,len(ids))
PYEOF
P=packages/cultmath/tools
python3 - <<'PY'
p='packages/cultmath/tools/device/run-lavapipe.sh'
s=open(p).read()
k='python3 "$DEV/gen.py" "$W/work" "$CM"\n'
s=s.replace(k,k+'sed -i -E "s/[.](cos|sin)([^A-Za-z0-9_])/._\\1\\2/g" "$W"/work/*.comp\n')
open(p,'w').write(s)
PY
bash $P/glsl/build-glsl.sh /tmp/glslout >/dev/null
GLSL_DIR=/tmp/glslout bash $P/device/run-lavapipe.sh 2>&1 | tail -4 || true
set +e
W=/tmp/device/work; R=/tmp/device
DXC=$(find $R/dxc -name dxc -type f | head -1); export LD_LIBRARY_PATH=$(dirname $DXC)/../lib:$(dirname $DXC)
CM=packages/cultmath
for d in plain ncod gis; do mkdir -p $R/$d/lavapipe-hlsl $R/$d/lavapipe-glsl; done
while IFS=$'\t' read -r tag _ _ n _ wout; do
spirv-dis --raw-id $W/$tag.g.spv -o /tmp/g.dis; python3 /tmp/nc.py /tmp/g.dis /tmp/g.nc.dis >/dev/null; spirv-as --target-env vulkan1.2 /tmp/g.nc.dis -o /tmp/g.nc.spv
$DXC -T cs_6_0 -E main -HV 2021 -spirv -fspv-target-env=vulkan1.2 -Od -I $CM/shaders $W/$tag.hlsl -Fo /tmp/hOd.spv >/dev/null 2>&1
spirv-dis --raw-id /tmp/hOd.spv -o /tmp/hOd.dis; python3 /tmp/nc.py /tmp/hOd.dis /tmp/hOd.nc.dis >/dev/null; spirv-as --target-env vulkan1.2 /tmp/hOd.nc.dis -o /tmp/hOd.nc.spv
$DXC -T cs_6_0 -E main -HV 2021 -spirv -fspv-target-env=vulkan1.2 -Gis -I $CM/shaders $W/$tag.hlsl -Fo /tmp/hGis.spv >/dev/null 2>&1
spirv-dis --raw-id /tmp/hGis.spv -o /tmp/hGis.dis; python3 /tmp/nc.py /tmp/hGis.dis /tmp/hGis.nc.dis >/dev/null; spirv-as --target-env vulkan1.2 /tmp/hGis.nc.dis -o /tmp/hGis.nc.spv
cp $R/results/lavapipe-hlsl/$tag.out $R/plain/lavapipe-hlsl/; cp $R/results/lavapipe-glsl/$tag.out $R/plain/lavapipe-glsl/
$R/vkrun /tmp/hOd.nc.spv $W/$tag.in $R/ncod/lavapipe-hlsl/$tag.out $n $wout >/dev/null
$R/vkrun /tmp/g.nc.spv $W/$tag.in $R/ncod/lavapipe-glsl/$tag.out $n $wout >/dev/null
$R/vkrun /tmp/hGis.nc.spv $W/$tag.in $R/gis/lavapipe-hlsl/$tag.out $n $wout >/dev/null
cp $R/ncod/lavapipe-glsl/$tag.out $R/gis/lavapipe-glsl/
for d in plain ncod gis; do cmp -s $R/$d/lavapipe-hlsl/$tag.out $R/$d/lavapipe-glsl/$tag.out || echo "EQ-FAIL $d $tag"; done
done <$W/manifest.txt
export CULTMATH_DEVICE_WORK=$W CULTMATH_DEVICE_NAME=$(sort -u $R/device.txt | head -1 | sed 's/^device=//') CULTMATH_DEVICE_IDENTITY=x CULTMATH_WRITE_DEVICE_BOUNDS=1
B=packages/cultmath/tests/CultMath.Tests/fixtures/device-bounds-lavapipe.txt
for v in plain ncod gis; do
export CULTMATH_DEVICE_RESULTS=$R/$v
dotnet test packages/cultmath/tests/CultMath.Tests --no-build --filter "FullyQualifiedName~GlslMirrorTests.DeviceResultsHoldFixture" >/tmp/judge.$v.log 2>&1 || true
grep -aE "Error Message" -A8 /tmp/judge.$v.log | head -14; ls -la $B; cp $B /tmp/bounds.$v; echo "judge $v: $(tail -3 /tmp/judge.$v.log | tr '\n' ' ')"
done
for v in plain ncod gis; do awk -F'\t' -v v=$v '{k=$1; u[k]+=$5; if($5+0>m[k]+0)m[k]=$5; c[k]++} END{for(k in c) print "BOUNDS",v,k,"families",c[k],"maxulp",m[k],"sumulp",u[k]}' /tmp/bounds.$v; done
for v in plain ncod; do awk -F'\t' '{print $1"/"$2"\t"$5}' /tmp/bounds.$v | sort > /tmp/b.$v; done
join -t$'\t' /tmp/b.plain /tmp/b.ncod | awk -F'\t' '$2!=$3{print "ULPDIFF",$1,"plain",$2,"ncod",$3}' | head -60

View file

@ -0,0 +1,111 @@
# Shader variable-length inputs: how shader libraries take a span, and what CultMath's build route allows
For `cultmath-tapes:follow_up:shader-weighted-lattice-loop` and `cultmath-tapes:follow_up:lattice-shader-pins` (campaign `cultmath-tapes`).
Imagination pass, agent `imagination-ag4-shader-loop`, session `self-2026-10-16-ag4`, 2026-10-17, against CultLib `origin/main` `e2051c5a`
and the build branch `hands/cultmath-glsl-build-dxc` `23ac4e89`. Probes are in `docs/research/shader-variable-inputs/`.
Tags: [src] primary source text read this pass; [web] search summary only; [probe] run this pass, output quoted below.
## 1. The question
C#'s `weighted_lattice_cells` reads the body span once per lattice cell (the weight is the exact power border of the
cell centre), and the ring search around it is the part a consumer cannot safely write by hand: the exit bound,
the two passes and the box-independent fold order. The shader port has the four steps only. A shader function cannot
take `ReadOnlySpan`, the library targets GLSL ES 3.00 fragment shaders (WebGL2: no storage buffers), and the GLSL is
built from the HLSL by dxc `lib_6_3` SPIR-V plus SPIRV-Cross (ruling `glsl-es-via-dxc-spirv-cross`).
So how do bodies reach a shader `weighted_lattice_cells`?
## 2. Prior art: how shader code that ships as a library takes a variable number of inputs
- [src] Unity URP (`ShaderLibrary/Input.hlsl`, `RealtimeLights.hlsl`, Graphics `master`): a fixed capacity
`MAX_VISIBLE_LIGHTS` chosen per platform (`MAX_VISIBLE_LIGHT_COUNT_LOW_END_MOBILE` under `SHADER_API_GLES30`,
`_MOBILE` for GLES3/WebGPU, `_DESKTOP` otherwise), constant-buffer arrays `float4 _AdditionalLightsPosition[MAX_VISIBLE_LIGHTS]`
and the like, with `StructuredBuffer<LightData>` only behind `USE_STRUCTURED_BUFFER_FOR_LIGHT_DATA` (defined 0). The
count is a uniform read through `GetAdditionalLightsCount()`, and `GetAdditionalLight(uint i, float3 positionWS)` is
the accessor; `LIGHT_LOOP_BEGIN(lightCount)` / `LIGHT_LOOP_END` macros hide the cluster versus plain loop. The
pipeline owns the storage and the accessor; user shaders call them. Capacity plus count, storage owned by the include.
- [src] Filament (`shaders/src/surface_light_punctual.fs`, `main`): the loop is the engine's, over a froxel record
(`froxelsUniforms.records`, then `froxelRecordUniforms.records`) giving `recordOffset` and `count`; each light is
`lightsUniforms.lights[lightIndex]`, a uniform-block array. Material code never sees the loop. The engine owns both
the storage and the loop because it also owns the uniform layout.
- [src] three.js (`ShaderChunk/lights_fragment_begin.glsl.js`, `dev`): `for ( int i = 0; i < NUM_POINT_LIGHTS; i ++ )`
under `#pragma unroll_loop_start`, reading `pointLights[ i ]`; `NUM_POINT_LIGHTS` is a define the renderer writes,
so the shader is regenerated per light count. Count as a compile-time constant; the library is source text.
- [web] Bevy: clustered forward needs storage buffers; on WebGL2 it falls back to a uniform buffer with a capped
object count (`MAX_UNIFORM_BUFFER_CLUSTERABLE_OBJECTS` 204 in an older `bevy_pbr`, asserted to fit 16384 bytes; the
Many Lights example says 256 lights under `WGPU_SETTINGS_PRIO=webgl2`). Capacity plus count where storage is absent.
- [web] lygia (README conventions, via search): reuse by `#ifndef` / `#define` options the caller sets before the
include (`SAMPLER_FNC(TEX, UV)`, `MYFUNC_TYPE`, per-function sampler overrides). Caller-supplied accessor macros; the
library ships as source text, which is what lets a macro reach inside a function body.
The pattern that splits them: a caller-supplied accessor (lygia, URP's accessor functions, three.js's define) works
where the library is source text the caller's preprocessor sees. Where the library is compiled or its storage layout is
fixed (URP's GLES path, Bevy's WebGL2 fallback, Filament's uniform blocks), the input is a fixed-capacity array plus a
count, and the capacity is a named library constant.
## 3. What the build route allows (probes)
| Candidate | HLSL (dxc) | Library SPIR-V and SPIRV-Cross GLSL | C# mirror (`TransformHlslToCSharp`) | Verdict |
|---|---|---|---|---|
| Fixed-size array parameter plus count | compiles, `lib_6_3` and `cs_6_0` [probe P1, P3] | see P2 | needs one transform (`T x[N]` parameter to `T[] x`) | see section 4 |
| Caller accessor macro (`CULTMATH_BODY(i)`) | the library build defines no accessor, so the function is absent [probe P4] | absent from the built GLSL: SPIRV-Cross output carries no consumer macro | the mirror would need the macro too | refused by the route |
| Bodies packed into library-owned constants | a library cbuffer or uniform | a uniform block in a function-only library; a binding the consumer does not own | a static field | refused (P5, by construction, not run): a library may not own a consumer's binding |
Probes (files in `shader-variable-inputs/`):
- [probe] P1, local, dxc 2026_07_29 (`.tools/dxc`): `lattice-array.hlsl` (CultMath.hlsl, `prototype.hlsl`, one export)
compiles with `-T lib_6_3 -HV 2021 -spirv -fspv-target-env=universal1.5 -Od`. The SPIR-V types the array as
`OpTypeArray %v4float %uint_16` behind a Function pointer, and `-Od` copies it at each call (`param_var_bodies`).
- [probe] P3, local: `entry-cbuffer.hlsl` passes a cbuffer array directly and a `StructuredBuffer` copied into a local
array; both compile to SPIR-V (`cs_6_0`) and to DXIL.
- [probe] P4, local: `macro-accessor.hlsl` with no `CULTMATH_BODY` defined fails, `use of undeclared identifier`: a
macro-parameterised function does not exist in a library compiled without the caller's macros, so the built GLSL
cannot carry it. Shipping it would need a hand-kept GLSL text beside the build, a second owner the ruling removed.
- [probe] P2, Yggdrasil job (`probe.sh` at `23ac4e89`, sdk:10.0, 2026-10-17): with the prototype appended to
`CultMath.hlsl` and two build-tool edits (`wrappers.py` strips `[N]` from an argument name; `partition.py` accepts
`static const int`), `build-glsl.sh` is green ("build shape ok", "suffixed ids: 0; inout: 0") and emits
`const int CULTMATH_MAX_SITES = 16;`, `vec4 cultmath_power_border(vec3 p, vec4 sites[16], int count)` and
`vec4 cultmath_weighted_lattice_cells(vec2 p, float time, vec4 bodies[16], int count, ...)`. A `#version 300 es`
fragment consumer passing `uniform vec4 zone[CULTMATH_MAX_SITES]` compiles with glslang.
- [probe] Same job, lavapipe (`llvmpipe (LLVM 20.1.2, 256 bits)`), 257 cases from `ref/Program.cs` (the ring-17 point
of `TheCapIsReachedAndMatchesTheFullBox` and 7 neighbours, four looks, five normals including (3e-30, -4e-30) and
(1e30, 2e30), a zero normal at stretch 1, counts 0, 1, 3, 6, 16), judged against C#'s bits at 2.2e-6 max(|v|, 1):
| Variant (HLSL route unless named) | bit-exact | over bound | worst |
|---|---|---|---|
| built GLSL | 237 | 0 | 1.19e-6 |
| HLSL (equal to built GLSL bit for bit) | 237 | 0 | 1.19e-6 |
| M1 exit at `0.5 k` | 236 | 1 | 4.28e-3 |
| M2 exit ignores the smooth band (`>= f2`) | 226 | 13 | 8.33e-2 |
| M3 normaliser drops the divide by the largest component | 148 | 96 | 1e30 |
| M4 normaliser drops the divide by length | 1 | 208 | 0.259 |
| M5 smooth step loses its `min` cap | 153 | 85 | 2.26e-3 |
| M6 box pass skips its first row | 228 | 9 | 0.807 |
| M7 exit one ring late (`0.5 (k - 2)`) | 237 | 0 | 1.19e-6 |
M7 is equivalent: the result does not depend on the box (the proof above `weighted_lattice_cells`), so a late exit
costs rings and changes nothing. M1 is killed by one case of 257, the ring-17 point: an early exit only matters where
a far ring wins, so the device family must carry several such points.
## 4. Decision
The fixed-size array plus count is the only candidate the route admits, and it is the shape the compiled-library
prior art uses (URP's GLES path, Bevy's WebGL2 fallback, Filament's uniform blocks). The capacity is a named library
constant, `CULTMATH_MAX_SITES`, shared by every span-taking shader function. Its value is
`cultmath-tapes:question:shader-site-capacity` (Aetheria zones: p50 17, p99 57, max 80 bodies, gravity map B28).
The shader recomputes each distance in the box pass instead of caching it: C#'s 75 x 75 stack cache is not a shader
option, and recomputing gives the same value, as the probe's bit-exact HLSL and built-GLSL agreement shows.
Reflection cannot pass a `ReadOnlySpan`, so the mirror and the fixture reach the C# span functions through one
explicit adapter table rather than `MethodInfo.Invoke`.
## 5. lattice-shader-pins
- The smooth-step family draws m in [f1, f1 + smoothing), so the cap never binds. The cap binds when m = f1 + smoothing,
d = f1 + x smoothing with x around 1.3 to 1.9, and smoothing above about 0.25: next - m = (d - m) h - h (1 - h)
smoothing / (1 + 3 smoothing) is positive there (for smoothing 1 and d - m = 0.5, h = 0.156 and next - m = +0.045).
M5 above is killed by the whole function already; the step family should kill it on its own.
- Platform values: `8b31e47e` rewrote 7 platform-family entries of `glsl-parity.json` from "Ubuntu 24.04.5 LTS" to
"Microsoft Windows 10.0.26200", while `7ddb1040` recorded the lavapipe bounds on Yggdrasil against that fixture.
The bounds, the lavapipe job and the build route all run in Yggdrasil's sdk:10.0 Ubuntu image, so that image
owns the fixture's platform values too. A write on another OS is refused rather than reviewed.

View file

@ -0,0 +1,15 @@
// Probe C: a compute entry passing a constant-buffer array, and one passing a StructuredBuffer copied to a local array.
#include "lattice-array.hlsl"
cbuffer Zone : register(b0) { float4 zoneBodies[CULTMATH_MAX_SITES]; int zoneCount; };
StructuredBuffer<float4> bufferBodies : register(t1);
RWStructuredBuffer<float4> result : register(u2);
[numthreads(64, 1, 1)]
void main(uint3 id : SV_DispatchThreadID)
{
float2 p = float2(id.x * 7.0, id.y * 11.0);
float4 local[CULTMATH_MAX_SITES];
for (int i = 0; i < CULTMATH_MAX_SITES; i++)
local[i] = i < zoneCount ? bufferBodies[i] : float4(0.0, 0.0, 0.0, 1.0);
result[id.x] = cultmath_weighted_lattice_cells(p, 1.0, zoneBodies, zoneCount, 260.0, 0.3, 1.6, 120.0, 0.35, float2(1.0, 2.0), 1.5)
+ cultmath_weighted_lattice_cells(p, 1.0, local, zoneCount, 260.0, 0.3, 1.6, 120.0, 0.35, float2(1.0, 2.0), 1.5);
}

View file

@ -0,0 +1,16 @@
layout(local_size_x = 64) in;
layout(std430, binding = 0) readonly buffer IB { uint a[]; } In;
layout(std430, binding = 1) buffer OB { uint a[]; } Out;
void main()
{
uint i = gl_GlobalInvocationID.x; if (i >= NCASES) return; uint b = i * 76u;
vec4 bodies[CULTMATH_MAX_SITES];
for (int k = 0; k < CULTMATH_MAX_SITES; k++)
bodies[k] = uintBitsToFloat(uvec4(In.a[b + 2u + 4u * uint(k)], In.a[b + 3u + 4u * uint(k)], In.a[b + 4u + 4u * uint(k)], In.a[b + 5u + 4u * uint(k)]));
uint c = b + 67u;
vec4 r = cultmath_weighted_lattice_cells(uintBitsToFloat(uvec2(In.a[b], In.a[b + 1u])), uintBitsToFloat(In.a[c]), bodies, int(In.a[b + 66u]),
uintBitsToFloat(In.a[c + 1u]), uintBitsToFloat(In.a[c + 2u]), uintBitsToFloat(In.a[c + 3u]), uintBitsToFloat(In.a[c + 4u]),
uintBitsToFloat(In.a[c + 5u]), uintBitsToFloat(uvec2(In.a[c + 6u], In.a[c + 7u])), uintBitsToFloat(In.a[c + 8u]));
uvec4 w = floatBitsToUint(r);
Out.a[i * 4u] = w.x; Out.a[i * 4u + 1u] = w.y; Out.a[i * 4u + 2u] = w.z; Out.a[i * 4u + 3u] = w.w;
}

View file

@ -0,0 +1,16 @@
// Probe compute entry (HLSL route): 76 words in per case (p, 16 bodies, count, 9 parameters), 4 words out.
#include "CultMath.hlsl"
[[vk::binding(0)]] StructuredBuffer<uint> In;
[[vk::binding(1)]] RWStructuredBuffer<uint> Out;
[numthreads(64, 1, 1)] void main(uint3 id : SV_DispatchThreadID)
{
uint i = id.x; if (i >= NCASES) return; uint b = i * 76u;
float4 bodies[CULTMATH_MAX_SITES];
for (int k = 0; k < CULTMATH_MAX_SITES; k++)
bodies[k] = float4(asfloat(In[b + 2 + 4 * k]), asfloat(In[b + 3 + 4 * k]), asfloat(In[b + 4 + 4 * k]), asfloat(In[b + 5 + 4 * k]));
uint c = b + 67u;
float4 r = cultmath_weighted_lattice_cells(float2(asfloat(In[b]), asfloat(In[b + 1])), asfloat(In[c]), bodies, asint(In[b + 66]),
asfloat(In[c + 1]), asfloat(In[c + 2]), asfloat(In[c + 3]), asfloat(In[c + 4]), asfloat(In[c + 5]),
float2(asfloat(In[c + 6]), asfloat(In[c + 7])), asfloat(In[c + 8]));
Out[i * 4] = asuint(r.x); Out[i * 4 + 1] = asuint(r.y); Out[i * 4 + 2] = asuint(r.z); Out[i * 4 + 3] = asuint(r.w);
}

View file

@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""Compare a device output with C#'s reference bits: cases over the lavapipe bound 2.2e-6 max(|v|, 1), and the largest.
usage: judge.py <ref.bin> <out.bin> <label>"""
import math, struct, sys
ref, out = open(sys.argv[1], 'rb').read(), open(sys.argv[2], 'rb').read()
n = len(ref) // 16
r, o = struct.unpack('<%df' % (4 * n), ref), struct.unpack('<%df' % (4 * n), out)
over, exact, worst = 0, 0, 0.0
for i in range(n):
s = max((abs(o[4*i+j] - r[4*i+j]) / max(abs(r[4*i+j]), 1.0) if math.isfinite(o[4*i+j]) else math.inf) for j in range(4))
exact += ref[16*i:16*i+16] == out[16*i:16*i+16]
over += s > 2.2e-6
worst = max(worst, s)
print('%s: cases %d, bit-exact %d, over 2.2e-6 %d, worst %.3g' % (sys.argv[3], n, exact, over, worst))

View file

@ -0,0 +1,7 @@
// Probe A (local dxc): the prototype as a lib_6_3 SPIR-V library export, bodies as a fixed-size array parameter plus a count.
#include "CultMath.hlsl"
#include "prototype.hlsl"
export float4 probe_lattice(float2 p, float4 bodies[CULTMATH_MAX_SITES], int count)
{
return cultmath_weighted_lattice_cells(p, 1.0, bodies, count, 260.0, 0.3, 1.6, 120.0, 0.35, float2(1.0, 2.0), 1.5);
}

View file

@ -0,0 +1,12 @@
// Probe B: bodies through a caller-supplied accessor macro. The library build defines no accessor.
#include "CultMath.hlsl"
#ifdef CULTMATH_BODY
float4 cultmath_power_border_m(float3 p)
{
float4 border = float4(0.0, 0.0, 0.0, 1.0e30);
for (int k = 1; k < CULTMATH_BODY_COUNT; k++)
border = cultmath_power_border_step(p, CULTMATH_BODY(0), CULTMATH_BODY(k), border);
return border;
}
#endif
export float4 probe_macro(float3 p) { return cultmath_power_border_m(p); }

View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Probe for shader-weighted-lattice-loop: the prototype through the dxc + SPIRV-Cross build and on lavapipe, against C#.
# Run from a CultLib clone at a revision with packages/cultmath/tools/glsl/build-glsl.sh (hands/cultmath-glsl-build-dxc
# 23ac4e89), inside mcr.microsoft.com/dotnet/sdk:10.0, with this directory at $P. The clone is disposable: the
# prototype is appended to its CultMath.hlsl, and two build-tool lines are patched (array arguments, int constants).
set -euo pipefail
P=${P:-/tmp/probe}; ROOT=$(pwd); CM=$ROOT/packages/cultmath; W=/tmp/pw; mkdir -p $W
apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends python3 >/dev/null
cat $P/prototype.hlsl >> $CM/shaders/CultMath.hlsl
python3 - $CM/tools/glsl/wrappers.py <<'PY'
import sys; f = sys.argv[1]; t = open(f).read()
t = t.replace(r"args = [re.split(r'\s+', p)[-1] for p in params]", r"args = [re.split(r'\s+', re.sub(r'\[.*\]', '', p))[-1] for p in params]")
open(f, 'w').write(t)
PY
grep -c "re.sub(r'" $CM/tools/glsl/wrappers.py
python3 - $CM/tools/glsl/partition.py <<'PY'
import sys; f = sys.argv[1]; t = open(f).read()
t = t.replace(r"r'^static const float (CULTMATH_\w+) = (-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?);$'", r"r'^static const (float|int) (CULTMATH_\w+) = (-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?);$'")
t = t.replace("consts[o].append('const float %s = %s;' % m.groups())", "consts[o].append('const %s %s = %s;' % m.groups())")
open(f, 'w').write(t)
PY
grep -c "(float|int)" $CM/tools/glsl/partition.py
echo "== build"
bash $CM/tools/glsl/build-glsl.sh $W/built 2>&1 | tail -12
echo "== array functions in the built core.glsl"
grep -nE "^(const int CULTMATH_MAX_SITES|vec4 cultmath_power_border\(|float cultmath_lattice_cell_distance\(|vec4 cultmath_weighted_lattice_cells\()" $W/built/core.glsl
echo "== ES 3.00 fragment consumer passing a uniform array"
{ printf '#version 300 es\nprecision highp float;\nprecision highp int;\n'; cat $W/built/core.glsl
printf 'uniform vec4 zone[CULTMATH_MAX_SITES];\nuniform int zoneCount;\nout highp vec4 c;\nvoid main() { c = cultmath_weighted_lattice_cells(gl_FragCoord.xy, 1.0, zone, zoneCount, 260.0, 0.7, 1.6, 120.0, 0.35, vec2(0.6, 0.8), 1.5); }\n'; } > $W/consumer.frag
glslangValidator $W/consumer.frag && echo "consumer ES 3.00 ok"
echo "== lavapipe"
apt-get install -y -qq --no-install-recommends mesa-vulkan-drivers libvulkan-dev gcc libc6-dev >/dev/null
gcc -O2 -o $W/vkrun $CM/tools/device/vkrun.c -lvulkan
cp -r $P/ref $W/ref && sed -i 's#../../../../packages#'"$CM"'/..#' $W/ref/Ref.csproj
N=$(dotnet run --project $W/ref -c Release -- $W/in.bin $W/ref.bin | tail -1); echo "cases $N"
DXC=$(find $CM/.tools/glsl/dxc -name dxc -type f | head -1); export LD_LIBRARY_PATH=$(dirname $DXC)/../lib:$(dirname $DXC)
{ printf '#version 310 es\nprecision highp float;\nprecision highp int;\n#define NCASES %su\n' $N; cat $W/built/core.glsl $P/entry.comp.tail; } > $W/e.comp
glslangValidator -V --target-env vulkan1.2 -o $W/g.spv $W/e.comp >$W/g.log || { head $W/g.log; exit 1; }
$W/vkrun $W/g.spv $W/in.bin $W/g.out $N 4
python3 $P/judge.py $W/ref.bin $W/g.out built-glsl
run_hlsl() { # $1 label, $2 sed expression applied to a copy of the shaders
rm -rf $W/s && cp -r $CM/shaders $W/s && sed -i "$2" $W/s/CultMath.hlsl
$DXC -T cs_6_0 -E main -HV 2021 -spirv -fspv-target-env=vulkan1.2 -D NCASES=${N}u -I $W/s $P/entry.hlsl -Fo $W/h.spv >$W/h.log 2>&1 || { head -5 $W/h.log; exit 1; }
$W/vkrun $W/h.spv $W/in.bin $W/h.out $N 4 >/dev/null
python3 $P/judge.py $W/ref.bin $W/h.out "$1"
}
run_hlsl hlsl 's/^$//'
cmp $W/h.out $W/g.out && echo "hlsl equals built glsl bit for bit" || echo "hlsl and built glsl differ (plain form; the gate is strict form)"
run_hlsl "M1 exit at 0.5k" 's/0\.5 \* (k - 1) >= max/0.5 * k >= max/'
run_hlsl "M2 exit ignores the smooth band" 's/>= max(f2, f1 + 2\.0 \* smoothing))$/>= f2)/'
run_hlsl "M3 normaliser drops the pre-scale" 's/normal = stretchNormal \/ scale;/normal = stretchNormal;/'
run_hlsl "M4 normaliser drops the length" 's/normal = normal \/ length(normal);//'
run_hlsl "M5 smooth step loses its cap" 's/return min(next, f1 + smoothing);/return next;/'
run_hlsl "M6 box pass skips its first row" 's/for (int dz = -reach; dz <= reach; dz++)/for (int dz = -reach + 1; dz <= reach; dz++)/'
run_hlsl "M7 late exit (equivalent: window-independent)" 's/0\.5 \* (k - 1) >= max/0.5 * (k - 2) >= max/'

View file

@ -0,0 +1,75 @@
// The prototype, appended to CultMath.hlsl by probe.sh: the whole weighted_lattice_cells with bodies as a fixed-size array and a count.
static const int CULTMATH_MAX_SITES = 16;
float4 cultmath_power_border(float3 p, float4 sites[CULTMATH_MAX_SITES], int count)
{
CultSiteCells cells = cultmath_site_cells_empty();
for (int i = 0; i < count; i++)
cells = cultmath_site_cells_insert(cells, cultmath_power_score(p, sites[i]), i);
float4 border = float4(0.0, 0.0, 0.0, 1.0e30);
int owner = (int)cells.nearestSite;
if (owner < 0)
return border;
for (int k = 0; k < count; k++)
if (k != owner)
border = cultmath_power_border_step(p, sites[owner], sites[k], border);
return border;
}
float cultmath_lattice_cell_distance(float2 q, float2 cell, float time, float drift, float4 bodies[CULTMATH_MAX_SITES], int count,
float cellSize, float boost, float falloff, float2 normal, float stretch)
{
float3 site = cultmath_lattice_site(cell, time, drift);
float border = cultmath_power_border(float3((cell + 0.5) * cellSize, 0.0), bodies, count).w;
return cultmath_lattice_distance(q, site.xy, cultmath_lattice_weight(site.z, border, boost, falloff), normal, stretch);
}
float4 cultmath_weighted_lattice_cells(float2 p, float time, float4 bodies[CULTMATH_MAX_SITES], int count, float cellSize, float drift,
float boost, float falloff, float smoothing, float2 stretchNormal, float stretch)
{
float scale = max(abs(stretchNormal.x), abs(stretchNormal.y));
float2 normal = float2(1.0, 0.0);
if (scale > 0.0)
{
normal = stretchNormal / scale;
normal = normal / length(normal);
}
float2 q = p / cellSize;
float2 origin = floor(q);
int cx0 = (int)origin.x;
int cz0 = (int)origin.y;
float f1 = 1.0e30;
float f2 = 1.0e30;
int reach = 0;
for (int k = 0; k <= 37; k++)
{
if (k > 1 && 0.5 * (k - 1) >= max(f2, f1 + 2.0 * smoothing))
break;
for (int dz = -k; dz <= k; dz++)
{
int step = dz == -k || dz == k || k == 0 ? 1 : 2 * k;
for (int dx = -k; dx <= k; dx += step)
{
float d = cultmath_lattice_cell_distance(q, float2(cx0 + dx, cz0 + dz), time, drift, bodies, count, cellSize, boost, falloff, normal, stretch);
if (d < f1) { f2 = f1; f1 = d; }
else if (d < f2) f2 = d;
}
}
reach = k;
}
float m = f1 + smoothing;
float best = 1.0e30;
float second = 1.0e30;
int winDx = 0;
int winDz = 0;
for (int dz = -reach; dz <= reach; dz++)
for (int dx = -reach; dx <= reach; dx++)
{
float d = cultmath_lattice_cell_distance(q, float2(cx0 + dx, cz0 + dz), time, drift, bodies, count, cellSize, boost, falloff, normal, stretch);
if (d < best) { second = best; best = d; winDx = dx; winDz = dz; }
else if (d < second) second = d;
m = cultmath_lattice_smooth_step(m, d, f1, smoothing);
}
int4 id = cultmath_pcg4d(int4(cx0 + winDx, cz0 + winDz, 0, 0));
return float4(best, second, m, (float)((uint)id.w >> 8) * (1.0 / 16777216.0));
}

View file

@ -0,0 +1,50 @@
// Probe reference: writes the lattice cases (76 words each) and C#'s weighted_lattice_cells bits (4 words each).
// usage: Ref <in.bin> <ref.bin>; prints the case count.
using CultMath;
using static CultMath.math;
const int Max = 16;
var cases = new List<(float2 p, float t, float4[] b, float cell, float drift, float boost, float falloff, float s, float2 n, float st)>();
float4[] Bodies(float t, int count)
{
var b = new float4[count];
for (var i = 0; i < count; i++)
{
var a = 0.001f * (i + 1) * t + i; var r = 300.0f + 220.0f * i;
b[i] = new float4(r * MathF.Cos(a), r * MathF.Sin(a), 0.0f, 40.0f + 40.0f * i);
}
return b;
}
// The ring-17 point of LatticeCellsTests.TheCapIsReachedAndMatchesTheFullBox, and its neighbours.
for (var j = 0; j < 8; j++)
cases.Add((new float2(1446.7345f + 3.0f * j, -680.7637f), 97.69269f, Bodies(97.69269f, 6), 260.0f, 0.7f, 4.0f, 1.0e6f, 1.0f, new float2(0.6f, 0.8f), 2.0f));
var looks = new[] { (1.6f, 1.0f, 0.35f), (1.6f, 2.0f, 0.35f), (4.0f, 2.0f, 1.0f), (0.0f, 1.0f, 0.05f) };
var normals = new[] { new float2(0.6f, 0.8f), new float2(3.0f, 4.0f), new float2(3.0e-30f, -4.0e-30f), new float2(1.0e30f, 2.0e30f), new float2(-1.0f, 1.0e-3f) };
var rng = new System.Random(20261017);
float U(float a, float b) => a + (b - a) * (float)rng.NextDouble();
for (var i = 0; i < 248; i++)
{
var (boost, stretch, s) = looks[i % 4];
var n = normals[(i / 4) % normals.Length];
var count = new[] { 6, 0, 1, 16, 6, 3 }[(i / 20) % 6];
var t = U(0.0f, 200.0f);
cases.Add((new float2(U(-3000, 3000), U(-3000, 3000)), t, Bodies(t, count), 260.0f, 0.7f, boost, 120.0f, s, n, stretch));
}
// Zero normal at stretch 1 is (1, 0).
cases.Add((new float2(10.0f, 20.0f), 1.0f, Bodies(1.0f, 6), 260.0f, 0.7f, 1.6f, 120.0f, 0.35f, new float2(0.0f, 0.0f), 1.0f));
using var input = new BinaryWriter(File.Create(args[0]));
using var reference = new BinaryWriter(File.Create(args[1]));
foreach (var c in cases)
{
input.Write(c.p.x); input.Write(c.p.y);
for (var k = 0; k < Max; k++)
{
var v = k < c.b.Length ? c.b[k] : new float4(0.0f, 0.0f, 0.0f, 0.0f);
input.Write(v.x); input.Write(v.y); input.Write(v.z); input.Write(v.w);
}
input.Write(c.b.Length);
foreach (var f in new[] { c.t, c.cell, c.drift, c.boost, c.falloff, c.s, c.n.x, c.n.y, c.st }) input.Write(f);
var r = weighted_lattice_cells(c.p, c.t, c.b, c.cell, c.drift, c.boost, c.falloff, c.s, c.n, c.st);
reference.Write(r.x); reference.Write(r.y); reference.Write(r.z); reference.Write(r.w);
}
Console.WriteLine(cases.Count);

View file

@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup>
<ItemGroup><ProjectReference Include="../../../../packages/cultmath/src/CultMath/CultMath.csproj" /></ItemGroup>
</Project>

View file

@ -38,8 +38,9 @@ shader semantics.
drifting lattice whose cell weights come from the exact power border of the body span at each cell centre, with a stretched
metric and an exact ring search over the stated range (`LatticeMaxBoost`, `LatticeMaxStretch`, `LatticeMaxSmoothing`). It composes the
public steps `lattice_site`, `lattice_weight`, `lattice_distance` and `lattice_smooth_step`. The shaders carry the four steps as
`cultmath_lattice_site`, `cultmath_lattice_weight`, `cultmath_lattice_distance` and `cultmath_lattice_smooth_step`; the ring and box loops
are the shader caller's, written as the comment above them in `CultMath.hlsl` gives them, and the argument checks become its preconditions.
`cultmath_lattice_site`, `cultmath_lattice_weight`, `cultmath_lattice_distance` and `cultmath_lattice_smooth_step`, and the whole function as `cultmath_weighted_lattice_cells(p, time, bodies,
count, ...)` over a `float4 bodies[CULTMATH_MAX_SITES]` array (128 by default; define the macro before including to change it);
`cultmath_power_border(p, sites, count)` is the matching border. The argument checks become the shader caller's preconditions.
- `shaders/CultMath.hlsl`, a canonical HLSL mirror include for shader-side
parity. HLSL already owns `float2`, `float3`, and `float4`; the include
exposes `cultmath_*` functions for shared semantics HLSL does not provide.

View file

@ -95,7 +95,8 @@ and HLSL's one-based `_11`..`_33` element names.
Roslyn against CultMath. These transformations are the complete list; if a body
needs anything else, the test fails:
1. Include guards (`#ifndef`/`#define`/`#endif`) are dropped.
1. Include guards (`#ifndef`/`#define`/`#endif`) are dropped, except the capacity macro (`#ifndef CULTMATH_MAX_SITES`,
`#define CULTMATH_MAX_SITES 128`, `#endif`), which becomes `const int CULTMATH_MAX_SITES = 128;`.
2. Functions taking `Texture2D`/`SamplerState` are dropped; GPU resources have
no CultMath analog.
3. File-scope `static const` becomes `const`.
@ -103,7 +104,8 @@ needs anything else, the test fails:
5. Struct fields gain `public`. HLSL struct members have no access-modifier
concept; a C# struct's fields default to `private`, which would hide them
from the mirror test's reflection-based field comparison.
6. The file body is wrapped in a class, since C# has no free functions; HLSL
6. An array parameter `T name[CULTMATH_MAX_SITES]` becomes `T[] name`.
7. The file body is wrapped in a class, since C# has no free functions; HLSL
functions become private instance methods.
HLSL constructs that stay outside the target and must not appear in the mirror:
@ -156,6 +158,10 @@ hand. Regenerate them, and the golden fixture
$env:CULTMATH_WRITE_GLSL = "1"; dotnet test packages/cultmath/tests/CultMath.Tests --filter GlslMirrorTests
```
Run it on Yggdrasil's `mcr.microsoft.com/dotnet/sdk:10.0` image (Ubuntu 24.04) only: that image owns the fixture's platform
families and `device-bounds-lavapipe.txt`, and `CULTMATH_WRITE_GLSL=1` fails on any other OS. Re-record the bounds there in
the same revision (`WRITE_BOUNDS=1`, `tools/device/run-lavapipe.sh`).
`GlslMirrorTests.CommittedGlslEqualsLowering` fails on any difference between
either committed file and the lowering of the committed HLSL. These
transformations, in order, are the complete list:
@ -423,7 +429,7 @@ together, laid out as `float4(gradient.xyz, value.w)`, with no value-only twin
- `phacelle(float3 p, float3 side, float offset, float normalization)` is Rune
Skovbo Johansen's Phacelle noise ("phase + cell": a stripe pattern built by
blending one cosine/sine wave per jittered cell), generalised to 3D cells. It
returns `CultPhasor { cos, sin }`, each `(∇, value)`. `side` is the stripe
returns `CultPhasor { cosine, sine }`, each `(∇, value)`. `side` is the stripe
wave vector, whose length is 2π times the stripe frequency; in 3D the
perpendicular to the flow is not unique, so the caller chooses it (on a unit
sphere, `cross(p̂, flow)`). `offset` is the phase in cycles. It visits 4×4×4
@ -519,11 +525,12 @@ running `float4(unit normal toward the owner, distance)` and the radical plane o
the distance built from the offset `owner - p` and the weight difference `(w1 - w2)(w1 + w2)` so it does not
cancel for close sites seen from far; the cell boundary distance is the least over every site except the owner
(the fold's nearest), and a site coincident with the owner has no plane. The step trusts the caller's owner: a
query outside the owner's cell reports distance 0 with that plane's normal, not an error. `power_border(p, sites)` is that loop.
query outside the owner's cell reports distance 0 with that plane's normal, not an error. `power_border(p, sites)` is that loop;
the shader's `cultmath_power_border(p, sites, count)` is the same loop over a by-value array (see "Sites in a shader" below).
Cell spans refuse a non-finite query, site position or weight, and more than 2^24 sites (indices are floats).
`power_cells` and
`multiplicative_cells` are that loop over a `ReadOnlySpan<float4>` and nothing else; a shader caller writes the
same loop over its own buffer. Every site of an explicit span is visited, so no window and no weight-ratio range
same loop over its own array. Every site of an explicit span is visited, so no window and no weight-ratio range
apply. `CultSiteCells` holds the two score fields and the two site indices (floats, -1 for none).
`gabriel_margin(a, b, other)` is `(|other - (a + b) / 2| - r) / r` with `r = |a - b| / 2`: zero on the diameter
@ -557,8 +564,21 @@ The accepted range is boost 0 to `LatticeMaxBoost` (4, a weight ratio of 10:1),
and smoothing up to `LatticeMaxSmoothing` (1); outside it the function throws `ArgumentException`. `LatticeMaxRing`
(37) is the proven greatest ring at the range edge. Measured over 24,000 points on orbiting bodies (six bodies,
sigma ratio 6:1, the ruled look and the range edge), the greatest ring visited has median 3, 99th percentile 7 and
maximum 8 (49, 225 and 289 cells); `RingsStayWellInsideTheCap` asserts these. The function is C# only; the shader
twins are the next cut.
maximum 8 (49, 225 and 289 cells); `RingsStayWellInsideTheCap` asserts these.
### Sites in a shader
A shader function cannot take a span, so `cultmath_power_border(p, sites, count)`, `cultmath_lattice_cell_distance(...)`
and `cultmath_weighted_lattice_cells(p, time, bodies, count, ...)` take `float4 sites[CULTMATH_MAX_SITES]` by value and the
count in use, with `count` in `[0, CULTMATH_MAX_SITES]`. `CULTMATH_MAX_SITES` is 128 unless the consumer defines it before
including the library (`#ifndef`): 128 covers every Aetheria zone within GLSL ES 3.00's 224 guaranteed fragment uniform
vectors, and loops run to `count`, so the capacity costs uniform space, not iterations. A caller whose sites live in a
buffer copies them into a local array first; the library takes no resource, accessor macro or uniform of its own. The preconditions the C#
functions throw for are the caller's in a shader (finite arguments, `cellSize > 0`, the stated ranges, `p` within 2^23
cells). The ring pass is bounded `k <= 37` and exits as C# does; the box pass recomputes each distance instead of caching
75 x 75 of them, which gives the same values. The C# mirror compiles the array as `T[]`, and
`HlslSourceCompatibilityTests.SiteArrayMirrorsMatchCSharp` compares the three functions through the span twins in
`SiteArrays` (reflection cannot pass a `ReadOnlySpan`); `CULTMATH_MAX_SITES` is a shader-only capacity with no C# twin.
## Intervals

View file

@ -14,8 +14,8 @@
// against CultMath.CultPhasor, same field order.
struct CultPhasor
{
vec4 cos;
vec4 sin;
vec4 cosine;
vec4 sine;
};
// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave
@ -84,13 +84,13 @@ CultPhasor cultmath_phacelle(vec3 p, vec3 side, float offset, float normalizatio
float outCos = rawCos / rawLength;
float outSin = rawSin / rawLength;
vec3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
result.cos = vec4((gradRawCos - outCos * gradLength) / rawLength, outCos);
result.sin = vec4((gradRawSin - outSin * gradLength) / rawLength, outSin);
result.cosine = vec4((gradRawCos - outCos * gradLength) / rawLength, outCos);
result.sine = vec4((gradRawSin - outSin * gradLength) / rawLength, outSin);
}
else
{
result.cos = vec4(gradRawCos / floorLength, rawCos / floorLength);
result.sin = vec4(gradRawSin / floorLength, rawSin / floorLength);
result.cosine = vec4(gradRawCos / floorLength, rawCos / floorLength);
result.sine = vec4(gradRawSin / floorLength, rawSin / floorLength);
}
return result;

View file

@ -14,8 +14,8 @@
// against CultMath.CultPhasor, same field order.
struct CultPhasor
{
float4 cos;
float4 sin;
float4 cosine;
float4 sine;
};
// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave
@ -84,13 +84,13 @@ CultPhasor cultmath_phacelle(float3 p, float3 side, float offset, float normaliz
float outCos = rawCos / rawLength;
float outSin = rawSin / rawLength;
float3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
result.cos = float4((gradRawCos - outCos * gradLength) / rawLength, outCos);
result.sin = float4((gradRawSin - outSin * gradLength) / rawLength, outSin);
result.cosine = float4((gradRawCos - outCos * gradLength) / rawLength, outCos);
result.sine = float4((gradRawSin - outSin * gradLength) / rawLength, outSin);
}
else
{
result.cos = float4(gradRawCos / floorLength, rawCos / floorLength);
result.sin = float4(gradRawSin / floorLength, rawSin / floorLength);
result.cosine = float4(gradRawCos / floorLength, rawCos / floorLength);
result.sine = float4(gradRawSin / floorLength, rawSin / floorLength);
}
return result;

View file

@ -600,14 +600,12 @@ CultCellular cultmath_cellular(vec3 p)
return result;
}
// Sites: a site is vec4(position.xyz, weight). A span is a loop over a per-site step, because a shader
// function cannot take a span in the mirror (design.md, "Sites"). The consumer's loop, over its own buffer:
// Sites: a site is vec4(position.xyz, weight). A shader function cannot take a span (design.md, "Sites"), so a span is a
// fixed-capacity array and a count (cultmath_power_border, below), or a loop over a per-site step:
// CultSiteCells cells = cultmath_site_cells_empty();
// for (int i = 0; i < count; i++)
// cells = cultmath_site_cells_insert(cells, cultmath_power_score(p, sites[i]), i);
// The exact power cell border is a second loop over the buffer, skipping the owner cells.nearestSite:
// vec4 border = vec4(0.0, 0.0, 0.0, 1.0e30); int owner = int(cells.nearestSite);
// for (int k = 0; k < count; k++) if (k != owner) border = cultmath_power_border_step(p, sites[owner], sites[k], border);
// The exact power cell border is cultmath_power_border, the second loop over the sites that skips the owner cells.nearestSite.
// The step trusts the owner: a p outside the owner's cell reports distance 0 with that plane's normal, not an error.
// Statement for statement with math.Sites.cs (power_score, multiplicative_score, site_cells_*, gabriel_margin).
struct CultSiteCells
@ -688,21 +686,8 @@ float cultmath_gabriel_margin(vec3 a, vec3 b, vec3 other)
return (cultmath_distance(other, (a + b) * 0.5) - r) / r;
}
// Weighted lattice cells: the four per-cell steps of math.Lattice.cs, statement for statement. A shader function cannot take a
// span (design.md, "Sites"), so weighted_lattice_cells is the consumer's loop over its own body buffer, in two passes:
// vec2 q = p / cellSize; ivec2 c0 = (ivec2)floor(q); float f1 = 1.0e30, f2 = 1.0e30; int reach = 0;
// for (int k = 0; ; k++) { // ring pass: Chebyshev rings 0, 1, 2, ... around c0, exact exit
// if (k > 1 && 0.5 * (k - 1) >= max(f2, f1 + 2.0 * smoothing)) break; // k never reaches 38 in the accepted range
// for each cell on ring k: d = cultmath_lattice_distance(q, cultmath_lattice_site(cell, time, drift).xy, w, normal, stretch),
// w = cultmath_lattice_weight(site.z, border, boost, falloff), border = the .w of cultmath_power_border_step
// folded over the bodies (owner = the nearest body of the cell centre (cell + 0.5) * cellSize, z = 0);
// if (d < f1) { f2 = f1; f1 = d; } else if (d < f2) f2 = d; store d for the box pass
// reach = k; }
// float m = f1 + smoothing; // box pass: every stored cell, rows dz = -reach..reach, then dx = -reach..reach
// for each (dz, dx): m = cultmath_lattice_smooth_step(m, d, f1, smoothing); // strict < for the winning id (earlier cell wins ties)
// The preconditions the C# function throws for are the caller's in a shader: finite arguments, cellSize > 0, boost in [0, 4],
// falloff > 0, smoothing in (0, 1], stretch in [1, 2], p within 2^23 cells. The normal is normalised as C# does: divide by its
// largest component, then by its length (a zero normal means (1, 0) and needs stretch == 1). The ring bound is 37.
// Weighted lattice cells: the four per-cell steps of math.Lattice.cs, statement for statement; cultmath_weighted_lattice_cells,
// after them, is the whole function.
vec3 cultmath_lattice_site(vec2 cell, float time, float drift)
{
ivec3 h = cultmath_pcg3d(ivec3(int(cell.x), int(cell.y), 0));
@ -734,6 +719,90 @@ float cultmath_lattice_smooth_step(float m, float d, float f1, float smoothing)
return min(next, f1 + smoothing);
}
// Sites by value: a shader function cannot take a span, so these take vec4 sites[CULTMATH_MAX_SITES] and the count in use, with
// count in [0, CULTMATH_MAX_SITES]. A consumer defines CULTMATH_MAX_SITES before including this file to change the capacity; loops
// run to count, so the capacity costs uniform space, not iterations. A caller whose sites live in a buffer copies them into a local array first.
// The preconditions the C# functions throw for are the caller's in a shader: finite arguments, cellSize > 0, boost in [0, 4],
// falloff > 0, smoothing in (0, 1], stretch in [1, 2], p within 2^23 cells. A zero stretchNormal means (1, 0) and needs stretch == 1.
// cultmath_weighted_lattice_cells is math.weighted_lattice_cells: the ring pass is bounded by k <= 37 and exits as C# does, and the
// box pass recomputes each distance instead of caching it (a 75 x 75 private array is not a shader option), with the same values.
#ifndef CULTMATH_MAX_SITES
#define CULTMATH_MAX_SITES 128
#endif
vec4 cultmath_power_border(vec3 p, vec4 sites[CULTMATH_MAX_SITES], int count)
{
CultSiteCells cells = cultmath_site_cells_empty();
for (int i = 0; i < count; i++)
cells = cultmath_site_cells_insert(cells, cultmath_power_score(p, sites[i]), i);
vec4 border = vec4(0.0, 0.0, 0.0, 1.0e30);
int owner = int(cells.nearestSite);
if (owner < 0)
return border;
for (int k = 0; k < count; k++)
if (k != owner)
border = cultmath_power_border_step(p, sites[owner], sites[k], border);
return border;
}
float cultmath_lattice_cell_distance(vec2 q, vec2 cell, float time, float drift, vec4 bodies[CULTMATH_MAX_SITES], int count,
float cellSize, float boost, float falloff, vec2 normal, float stretch)
{
vec3 site = cultmath_lattice_site(cell, time, drift);
float border = cultmath_power_border(vec3((cell + 0.5) * cellSize, 0.0), bodies, count).w;
return cultmath_lattice_distance(q, site.xy, cultmath_lattice_weight(site.z, border, boost, falloff), normal, stretch);
}
vec4 cultmath_weighted_lattice_cells(vec2 p, float time, vec4 bodies[CULTMATH_MAX_SITES], int count, float cellSize, float drift,
float boost, float falloff, float smoothing, vec2 stretchNormal, float stretch)
{
float scale = max(abs(stretchNormal.x), abs(stretchNormal.y));
vec2 normal = vec2(1.0, 0.0);
if (scale > 0.0)
{
normal = stretchNormal / scale;
normal = normal / length(normal);
}
vec2 q = p / cellSize;
vec2 origin = floor(q);
int cx0 = int(origin.x);
int cz0 = int(origin.y);
float f1 = 1.0e30;
float f2 = 1.0e30;
int reach = 0;
for (int k = 0; k <= 37; k++)
{
if (k > 1 && 0.5 * float(k - 1) >= max(f2, f1 + 2.0 * smoothing))
break;
for (int dz = -k; dz <= k; dz++)
{
int step = dz == -k || dz == k || k == 0 ? 1 : 2 * k;
for (int dx = -k; dx <= k; dx += step)
{
float d = cultmath_lattice_cell_distance(q, vec2(cx0 + dx, cz0 + dz), time, drift, bodies, count, cellSize, boost, falloff, normal, stretch);
if (d < f1) { f2 = f1; f1 = d; }
else if (d < f2) f2 = d;
}
}
reach = k;
}
float m = f1 + smoothing;
float best = 1.0e30;
float second = 1.0e30;
int winDx = 0;
int winDz = 0;
for (int dz = -reach; dz <= reach; dz++)
for (int dx = -reach; dx <= reach; dx++)
{
float d = cultmath_lattice_cell_distance(q, vec2(cx0 + dx, cz0 + dz), time, drift, bodies, count, cellSize, boost, falloff, normal, stretch);
if (d < best) { second = best; best = d; winDx = dx; winDz = dz; }
else if (d < second) second = d;
m = cultmath_lattice_smooth_step(m, d, f1, smoothing);
}
ivec4 id = cultmath_pcg4d(ivec4(cx0 + winDx, cz0 + winDz, 0, 0));
return vec4(best, second, m, float(uint(id.w) >> 8) * (1.0 / 16777216.0));
}
vec2 cultmath_segment_closest(vec2 point_value, vec2 start, vec2 end)
{
vec2 segment = end - start;

View file

@ -600,14 +600,12 @@ CultCellular cultmath_cellular(float3 p)
return result;
}
// Sites: a site is float4(position.xyz, weight). A span is a loop over a per-site step, because a shader
// function cannot take a span in the mirror (design.md, "Sites"). The consumer's loop, over its own buffer:
// Sites: a site is float4(position.xyz, weight). A shader function cannot take a span (design.md, "Sites"), so a span is a
// fixed-capacity array and a count (cultmath_power_border, below), or a loop over a per-site step:
// CultSiteCells cells = cultmath_site_cells_empty();
// for (int i = 0; i < count; i++)
// cells = cultmath_site_cells_insert(cells, cultmath_power_score(p, sites[i]), i);
// The exact power cell border is a second loop over the buffer, skipping the owner cells.nearestSite:
// float4 border = float4(0.0, 0.0, 0.0, 1.0e30); int owner = (int)cells.nearestSite;
// for (int k = 0; k < count; k++) if (k != owner) border = cultmath_power_border_step(p, sites[owner], sites[k], border);
// The exact power cell border is cultmath_power_border, the second loop over the sites that skips the owner cells.nearestSite.
// The step trusts the owner: a p outside the owner's cell reports distance 0 with that plane's normal, not an error.
// Statement for statement with math.Sites.cs (power_score, multiplicative_score, site_cells_*, gabriel_margin).
struct CultSiteCells
@ -688,21 +686,8 @@ float cultmath_gabriel_margin(float3 a, float3 b, float3 other)
return (cultmath_distance(other, (a + b) * 0.5) - r) / r;
}
// Weighted lattice cells: the four per-cell steps of math.Lattice.cs, statement for statement. A shader function cannot take a
// span (design.md, "Sites"), so weighted_lattice_cells is the consumer's loop over its own body buffer, in two passes:
// float2 q = p / cellSize; int2 c0 = (int2)floor(q); float f1 = 1.0e30, f2 = 1.0e30; int reach = 0;
// for (int k = 0; ; k++) { // ring pass: Chebyshev rings 0, 1, 2, ... around c0, exact exit
// if (k > 1 && 0.5 * (k - 1) >= max(f2, f1 + 2.0 * smoothing)) break; // k never reaches 38 in the accepted range
// for each cell on ring k: d = cultmath_lattice_distance(q, cultmath_lattice_site(cell, time, drift).xy, w, normal, stretch),
// w = cultmath_lattice_weight(site.z, border, boost, falloff), border = the .w of cultmath_power_border_step
// folded over the bodies (owner = the nearest body of the cell centre (cell + 0.5) * cellSize, z = 0);
// if (d < f1) { f2 = f1; f1 = d; } else if (d < f2) f2 = d; store d for the box pass
// reach = k; }
// float m = f1 + smoothing; // box pass: every stored cell, rows dz = -reach..reach, then dx = -reach..reach
// for each (dz, dx): m = cultmath_lattice_smooth_step(m, d, f1, smoothing); // strict < for the winning id (earlier cell wins ties)
// The preconditions the C# function throws for are the caller's in a shader: finite arguments, cellSize > 0, boost in [0, 4],
// falloff > 0, smoothing in (0, 1], stretch in [1, 2], p within 2^23 cells. The normal is normalised as C# does: divide by its
// largest component, then by its length (a zero normal means (1, 0) and needs stretch == 1). The ring bound is 37.
// Weighted lattice cells: the four per-cell steps of math.Lattice.cs, statement for statement; cultmath_weighted_lattice_cells,
// after them, is the whole function.
float3 cultmath_lattice_site(float2 cell, float time, float drift)
{
int3 h = cultmath_pcg3d(int3((int)cell.x, (int)cell.y, 0));
@ -734,6 +719,90 @@ float cultmath_lattice_smooth_step(float m, float d, float f1, float smoothing)
return min(next, f1 + smoothing);
}
// Sites by value: a shader function cannot take a span, so these take float4 sites[CULTMATH_MAX_SITES] and the count in use, with
// count in [0, CULTMATH_MAX_SITES]. A consumer defines CULTMATH_MAX_SITES before including this file to change the capacity; loops
// run to count, so the capacity costs uniform space, not iterations. A caller whose sites live in a buffer copies them into a local array first.
// The preconditions the C# functions throw for are the caller's in a shader: finite arguments, cellSize > 0, boost in [0, 4],
// falloff > 0, smoothing in (0, 1], stretch in [1, 2], p within 2^23 cells. A zero stretchNormal means (1, 0) and needs stretch == 1.
// cultmath_weighted_lattice_cells is math.weighted_lattice_cells: the ring pass is bounded by k <= 37 and exits as C# does, and the
// box pass recomputes each distance instead of caching it (a 75 x 75 private array is not a shader option), with the same values.
#ifndef CULTMATH_MAX_SITES
#define CULTMATH_MAX_SITES 128
#endif
float4 cultmath_power_border(float3 p, float4 sites[CULTMATH_MAX_SITES], int count)
{
CultSiteCells cells = cultmath_site_cells_empty();
for (int i = 0; i < count; i++)
cells = cultmath_site_cells_insert(cells, cultmath_power_score(p, sites[i]), i);
float4 border = float4(0.0, 0.0, 0.0, 1.0e30);
int owner = (int)cells.nearestSite;
if (owner < 0)
return border;
for (int k = 0; k < count; k++)
if (k != owner)
border = cultmath_power_border_step(p, sites[owner], sites[k], border);
return border;
}
float cultmath_lattice_cell_distance(float2 q, float2 cell, float time, float drift, float4 bodies[CULTMATH_MAX_SITES], int count,
float cellSize, float boost, float falloff, float2 normal, float stretch)
{
float3 site = cultmath_lattice_site(cell, time, drift);
float border = cultmath_power_border(float3((cell + 0.5) * cellSize, 0.0), bodies, count).w;
return cultmath_lattice_distance(q, site.xy, cultmath_lattice_weight(site.z, border, boost, falloff), normal, stretch);
}
float4 cultmath_weighted_lattice_cells(float2 p, float time, float4 bodies[CULTMATH_MAX_SITES], int count, float cellSize, float drift,
float boost, float falloff, float smoothing, float2 stretchNormal, float stretch)
{
float scale = max(abs(stretchNormal.x), abs(stretchNormal.y));
float2 normal = float2(1.0, 0.0);
if (scale > 0.0)
{
normal = stretchNormal / scale;
normal = normal / length(normal);
}
float2 q = p / cellSize;
float2 origin = floor(q);
int cx0 = (int)origin.x;
int cz0 = (int)origin.y;
float f1 = 1.0e30;
float f2 = 1.0e30;
int reach = 0;
for (int k = 0; k <= 37; k++)
{
if (k > 1 && 0.5 * (float)(k - 1) >= max(f2, f1 + 2.0 * smoothing))
break;
for (int dz = -k; dz <= k; dz++)
{
int step = dz == -k || dz == k || k == 0 ? 1 : 2 * k;
for (int dx = -k; dx <= k; dx += step)
{
float d = cultmath_lattice_cell_distance(q, float2(cx0 + dx, cz0 + dz), time, drift, bodies, count, cellSize, boost, falloff, normal, stretch);
if (d < f1) { f2 = f1; f1 = d; }
else if (d < f2) f2 = d;
}
}
reach = k;
}
float m = f1 + smoothing;
float best = 1.0e30;
float second = 1.0e30;
int winDx = 0;
int winDz = 0;
for (int dz = -reach; dz <= reach; dz++)
for (int dx = -reach; dx <= reach; dx++)
{
float d = cultmath_lattice_cell_distance(q, float2(cx0 + dx, cz0 + dz), time, drift, bodies, count, cellSize, boost, falloff, normal, stretch);
if (d < best) { second = best; best = d; winDx = dx; winDz = dz; }
else if (d < second) second = d;
m = cultmath_lattice_smooth_step(m, d, f1, smoothing);
}
int4 id = cultmath_pcg4d(int4(cx0 + winDx, cz0 + winDz, 0, 0));
return float4(best, second, m, (float)((uint)id.w >> 8) * (1.0 / 16777216.0));
}
float2 cultmath_segment_closest(float2 point_value, float2 start, float2 end)
{
float2 segment = end - start;

View file

@ -14,14 +14,14 @@ namespace CultMath;
public struct CultPhasor
{
/// <summary>(&#8711;cos, cos): the normalized cosine wave and its gradient.</summary>
public float4 cos;
public float4 cosine;
/// <summary>(&#8711;sin, sin): the normalized sine wave and its gradient.</summary>
public float4 sin;
public float4 sine;
public CultPhasor(float4 cos, float4 sin)
public CultPhasor(float4 cosine, float4 sine)
{
this.cos = cos;
this.sin = sin;
this.cosine = cosine;
this.sine = sine;
}
}

View file

@ -200,7 +200,7 @@ public static partial class math
return new float4(best, second, m, cellular_unit(id.w));
}
private static float lattice_cell_distance(
internal static float lattice_cell_distance(
float2 q, float2 cell, float time, float drift, ReadOnlySpan<float4> bodies, float cellSize, float boost, float falloff,
float2 normal, float stretch)
{

View file

@ -70,8 +70,9 @@ internal static class GlslLowering
var source = hlsl.ReplaceLineEndings("\n");
// 2. Include guards are dropped, and so are the functions taking Texture2D/SamplerState, exactly
// as the C# mirror drops them; the host shader samples its own textures.
source = Regex.Replace(source, @"(?m)^[ \t]*#(ifndef|define|endif)\b.*$", string.Empty);
// as the C# mirror drops them; the host shader samples its own textures. The capacity macro
// (#ifndef CULTMATH_MAX_SITES ... #endif) stays: GLSL has the preprocessor, and a consumer overrides it.
source = HlslSourceCompatibilityTests.DropGuards(source, (name, value) => $"#ifndef {name}\n#define {name} {value}\n#endif");
source = Regex.Replace(source, @"(?ms)^\w+ \w+\([^)]*\b(Texture2D|SamplerState)\b[^)]*\)\s*\{.*?^\}[ \t]*$", string.Empty);
// 3. Type names: floatN, intN, uintN and boolN become vecN, ivecN, uvecN and bvecN, and float3x3

View file

@ -0,0 +1,60 @@
using System.Reflection;
using System.Text.RegularExpressions;
using Xunit;
namespace CultMath.Tests;
public sealed partial class GlslMirrorTests
{
private const string Weighted = "cultmath_weighted_lattice_cells(";
private const string SmoothStep = "cultmath_lattice_smooth_step(";
// Edits of the shader text that each break one rule of the lattice loop. M7 (the exit one ring late) is absent: by the
// window-independence proof a later exit visits more cells and changes nothing, so no case can kill it.
private static readonly (string Name, string Function, string Old, string New, int Kills)[] LatticeMutants =
{
("M1 exit one ring early", Weighted, "0.5 * (float)(k - 1) >= max(f2", "0.5 * (float)k >= max(f2", 3),
("M2 exit ignores the smoothing band", Weighted, ">= max(f2, f1 + 2.0 * smoothing))", ">= f2)", 1),
("M3 no pre-normalisation of the normal", Weighted, "normal = stretchNormal / scale;", "normal = stretchNormal;", 1),
("M4 no unit length for the normal", Weighted, "normal = normal / length(normal);", "normal = normal * 1.0;", 1),
("M5 smooth step without its clamp", SmoothStep, "return min(next, f1 + smoothing);", "return next;", 1),
("M6 box pass skips its first row", Weighted, "for (int dz = -reach; dz <= reach; dz++)", "for (int dz = -reach + 1; dz <= reach; dz++)", 1),
};
/// <summary>
/// Each rule of the lattice loop is pinned by the committed fixture's own cases: the shader text with one rule broken (a
/// mutant mirror, the text a device runs) returns another result than the fixture on at least the stated number of
/// cases of the family that exercises it, the exit one ring early on at least three. The device judge holds the same
/// cases to their recorded bound, so a mutant these cases separate fails it too.
/// </summary>
[Fact]
public void TheFixtureCasesSeparateEveryLatticeMutantFromTheShader()
{
var families = FixtureFamilies();
foreach (var (name, function, old, replacement, kills) in LatticeMutants)
{
var (assembly, errors) = HlslSourceCompatibilityTests.CompileShaderMirror(mutate: text =>
{
Assert.True(text.Split(old).Length == 2, $"{name}: the shader text no longer holds exactly one \"{old}\"");
return text.Replace(old, replacement);
});
Assert.True(assembly is not null, name + ": " + string.Join(Environment.NewLine, errors));
var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!;
var shader = Activator.CreateInstance(shaderType);
var mirror = shaderType.GetMethod(function[..^1], BindingFlags.Instance | BindingFlags.NonPublic)!;
var f = Array.FindIndex(Families, family => family.Glsl.Contains(function));
var method = Resolve(Families[f].Glsl);
var killed = 0;
foreach (var entry in families[f]!["cases"]!.AsArray())
{
var sides = Sides(entry);
if (Hex(new[] { mirror.Invoke(shader, Arguments(method, sides[0]))! }) != sides[1])
killed++;
}
output.WriteLine($"{name}: separated on {killed} of {Points} cases of {Families[f].Glsl}");
Assert.True(killed >= kills, $"{name}: the {Families[f].Glsl} fixture cases separate it on {killed} of {Points}, fewer than {kills}");
}
}
}

View file

@ -22,7 +22,11 @@ public sealed partial class GlslMirrorTests
public GlslMirrorTests(ITestOutputHelper output) => this.output = output;
private static bool Write => Environment.GetEnvironmentVariable("CULTMATH_WRITE_GLSL") == "1";
// The fixture is written by Yggdrasil's mcr.microsoft.com/dotnet/sdk:10.0 image (Ubuntu 24.04) only: its platform families record
// that platform's libm, so a write from another OS would move them.
private static bool Write => Environment.GetEnvironmentVariable("CULTMATH_WRITE_GLSL") == "1"
? OperatingSystem.IsLinux() ? true : throw new InvalidOperationException("CULTMATH_WRITE_GLSL=1 writes glsl-parity.json, whose platform families belong to Yggdrasil's Ubuntu image (mcr.microsoft.com/dotnet/sdk:10.0): regenerate it there.")
: false;
private static string Root => HlslSourceCompatibilityTests.FindCultMathRoot();
@ -497,6 +501,14 @@ static int4 clamp(int4 x, int4 a, int4 b) { if (a.x > b.x || a.y > b.y || a.z >
return (int)scalars.Dequeue();
if (type == typeof(uint))
return scalars.Dequeue();
if (type.IsArray)
{
var array = Array.CreateInstance(type.GetElementType()!, SiteArrays.Capacity);
for (var i = 0; i < array.Length; i++)
array.SetValue(FromScalars(type.GetElementType()!, scalars), i);
return array;
}
var value = Activator.CreateInstance(type)!;
foreach (var field in type.GetFields().Where(f => !f.IsStatic))
field.SetValue(value, FromScalars(field.FieldType, scalars));
@ -872,14 +884,26 @@ static int4 clamp(int4 x, int4 a, int4 b) { if (a.x > b.x || a.y > b.y || a.z >
}),
// The smooth step: d exactly at f1 + 2 smoothing one in four (the equal branch), near it one in four, the
// smoothing near 0 and at 1, m anywhere up to f1 + smoothing.
// One draw in four is the cap point: m = f1 + smoothing exactly and d = f1 + smoothing (1 + U(0.3, 0.9)) with smoothing in
// [0.5, 1], where next exceeds f1 + smoothing and the min clamps; a step that returns next reads another function there.
new Family("float cultmath_lattice_smooth_step(float, float, float, float)", "ulp-bounded", r =>
{
if (r.Next(4) == 0)
{
var cappedSmoothing = Uniform(r, 0.5f, 1.0f);
var cappedF1 = Uniform(r, 0.0f, 10.0f);
return new object[] { cappedF1 + cappedSmoothing, cappedF1 + cappedSmoothing * (1.0f + Uniform(r, 0.3f, 0.9f)), cappedF1, cappedSmoothing };
}
var smoothing = r.Next(5) switch { 0 => 1.0f, 1 => Uniform(r, 1.0e-3f, 1.0e-2f), _ => Uniform(r, 0.01f, 1.0f) };
var f1 = Uniform(r, 0.0f, 10.0f);
var edge = f1 + 2.0f * smoothing;
var d = r.Next(4) switch { 0 => edge, 1 => edge * Uniform(r, 0.99f, 1.01f), _ => Uniform(r, f1, edge + 1.0f) };
return new object[] { Uniform(r, f1, f1 + smoothing), d, f1, smoothing };
}),
// The site-array functions: a fixed-capacity array and a count, killed on the device (the lattice at rings up to 17).
new Family($"vec4 cultmath_power_border(vec3, vec4[{SiteArrays.Capacity}], int)", "ulp-bounded", SiteArrays.PowerBorderArguments),
new Family($"vec4 cultmath_weighted_lattice_cells(vec2, float, vec4[{SiteArrays.Capacity}], int, float, float, float, float, float, vec2, float)", "ulp-bounded", SiteArrays.LatticeArguments),
}).ToArray();
// A point of magnitude m, m log-uniform in [lo, hi]: one component at +-m, the other two uniform in [-m, m],
@ -988,6 +1012,7 @@ static int4 clamp(int4 x, int4 a, int4 b) { if (a.x > b.x || a.y > b.y || a.z >
["vec4"] = typeof(float4), ["ivec3"] = typeof(int3), ["ivec4"] = typeof(int4),
["SpanPath"] = typeof(SpanPath), ["SpanStage"] = typeof(SpanStage),
["CultSiteCells"] = typeof(CultSiteCells),
[$"vec4[{SiteArrays.Capacity}]"] = typeof(float4[]),
};
// The C# math method a GLSL signature names: cultmath_ dropped, parameter types mapped.
@ -996,6 +1021,7 @@ static int4 clamp(int4 x, int4 a, int4 b) { if (a.x > b.x || a.y > b.y || a.z >
var match = Regex.Match(glsl, @"^\w+ cultmath_(\w+)\(([^)]*)\)$");
var types = match.Groups[2].Value.Split(", ").Select(t => GlslTypes[t]).ToArray();
return typeof(math).GetMethod(match.Groups[1].Value, BindingFlags.Public | BindingFlags.Static, null, types, null)
?? typeof(SiteArrays).GetMethod(match.Groups[1].Value, BindingFlags.NonPublic | BindingFlags.Static, null, types, null)
?? throw new InvalidOperationException("no C# math counterpart for " + glsl);
}
@ -1011,6 +1037,8 @@ static int4 clamp(int4 x, int4 a, int4 b) { if (a.x > b.x || a.y > b.y || a.z >
// the same read as GLSL's columns.
private static IEnumerable<object> Scalars(object value) => value is float or int or uint
? new[] { value }
: value is Array array
? array.Cast<object>().SelectMany(Scalars)
: value is float3x3 matrix
? new object[] { matrix._m00, matrix._m01, matrix._m02, matrix._m10, matrix._m11, matrix._m12, matrix._m20, matrix._m21, matrix._m22 }
: value.GetType().GetFields().Where(f => !f.IsStatic).SelectMany(f => Scalars(f.GetValue(value)!));

View file

@ -70,7 +70,7 @@ public sealed class HlslSourceCompatibilityTests
shader.GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic, null, parameters, null);
// Shader constants with no C# twin of the same name (empty when every constant has one).
private static readonly string[] ConstantsWithoutTwin = Array.Empty<string>();
private static readonly string[] ConstantsWithoutTwin = { "CULTMATH_MAX_SITES" };
/// <summary>
/// Every static const of the compiled HLSL mirror (a static literal CULTMATH_* field, whatever its access) is the
@ -85,7 +85,7 @@ public sealed class HlslSourceCompatibilityTests
Assert.True(assembly is not null, string.Join(Environment.NewLine, errors));
var shader = assembly!.GetType("CultMathHlsl.HlslShader")!;
var constants = shader.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
.Where(f => f.IsLiteral && f.Name.StartsWith("CULTMATH_", StringComparison.Ordinal))
.Where(f => f.IsLiteral && f.FieldType == typeof(float) && f.Name.StartsWith("CULTMATH_", StringComparison.Ordinal))
.ToArray();
Assert.True(constants.Length >= 10, $"only {constants.Length} shader constants were found by reflection");
foreach (var constant in constants)
@ -183,7 +183,7 @@ public sealed class HlslSourceCompatibilityTests
var mismatches = new List<string>();
var compared = new HashSet<string>();
foreach (var mirror in shaderType.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic).Where(m => m.Name.StartsWith("cultmath_")))
foreach (var mirror in shaderType.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic).Where(m => m.Name.StartsWith("cultmath_") && !m.GetParameters().Any(p => p.ParameterType.IsArray)))
{
var types = mirror.GetParameters().Select(p => p.ParameterType).ToArray();
var counterpart = Twin(shaderType, mirror.Name["cultmath_".Length..], types);
@ -204,9 +204,67 @@ public sealed class HlslSourceCompatibilityTests
}
Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}");
// The three site-array functions are compared by SiteArrayMirrorsMatchCSharp, which draws their counts and arrays.
Assert.Equal(140, compared.Count);
}
/// <summary>
/// The shader functions that take a site array and a count are bit-equal to C#'s span functions on the same
/// arguments: at least 64 compared cases each, counts 0 to the capacity with filler after the count (a loop bound that
/// ignores the count reads it), the lattice at rings up to 17. A case C# refuses is skipped and counted.
/// </summary>
[Fact]
public void SiteArrayMirrorsMatchCSharp()
{
var (assembly, errors) = CompileShaderMirror();
Assert.True(assembly is not null, string.Join(Environment.NewLine, errors));
var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!;
var shader = Activator.CreateInstance(shaderType);
Assert.Equal(17, SiteArrays.FarPoints[0].Ring);
Assert.All(SiteArrays.FarPoints, far => Assert.InRange(far.Ring, 8, LatticeMaxRing));
var draws = new (string Name, Func<System.Random, object[]> Draw)[]
{
("power_border", SiteArrays.PowerBorderArguments),
("lattice_cell_distance", SiteArrays.LatticeCellDistanceArguments),
("weighted_lattice_cells", SiteArrays.LatticeArguments),
};
foreach (var (name, draw) in draws)
{
var mirror = shaderType.GetMethod("cultmath_" + name, BindingFlags.Instance | BindingFlags.NonPublic)!;
var twin = typeof(SiteArrays).GetMethod(name, BindingFlags.Static | BindingFlags.NonPublic)!;
Assert.Equal(twin.GetParameters().Select(p => p.ParameterType), mirror.GetParameters().Select(p => p.ParameterType));
var random = new System.Random(0x517E);
int compared = 0, refused = 0;
for (var i = 0; i < 160; i++)
{
var args = draw(random);
object expected;
try { expected = twin.Invoke(null, args)!; }
catch (TargetInvocationException e) when (e.InnerException is ArgumentException) { refused++; continue; }
var actual = Values(mirror.Invoke(shader, args)!);
Assert.True(BitwiseEqual(Values(expected), actual),
$"{name} case {i}: C# {string.Join(" ", Values(expected).Select(v => v.Value))}, mirror {string.Join(" ", actual.Select(v => v.Value))}");
compared++;
}
Assert.True(compared >= 64, $"{name} compared only {compared} cases ({refused} refused)");
}
}
/// <summary>A consumer defines CULTMATH_MAX_SITES before including the library; the mirror compiles with the capacity changed.</summary>
[Fact]
public void TheSiteCapacityIsOverridable()
{
var (assembly, errors) = CompileShaderMirror(defines: new Dictionary<string, int> { ["CULTMATH_MAX_SITES"] = 4 });
Assert.True(assembly is not null, string.Join(Environment.NewLine, errors));
var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!;
Assert.Equal(4, (int)shaderType.GetField("CULTMATH_MAX_SITES", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!.GetRawConstantValue()!);
var sites = new[] { new float4(0, 0, 0, 1), new float4(10, 0, 0, 1), new float4(0, 10, 0, 2), new float4(-10, 0, 0, 1) };
var border = (float4)shaderType.GetMethod("cultmath_power_border", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(Activator.CreateInstance(shaderType), new object[] { new float3(1, 1, 0), sites, 4 })!;
Assert.Equal(power_border(new float3(1, 1, 0), sites), border);
}
/// <summary>
/// The 1500 arguments (a, b, lo, hi, t0, t1) at which integral_clamp_linear's zero-length rule, its lo
/// normalisation and its infinite bounds all bite: a in {-2.5, 0, 1.5}, b in {-1.5, -0, 0, 2}, lo in
@ -682,14 +740,23 @@ public sealed class HlslSourceCompatibilityTests
: string.Empty);
}
/// <summary>
/// The text without its include-guard lines. A capacity macro (<c>#ifndef X</c>, <c>#define X n</c>, <c>#endif</c>, a
/// consumer-overridable default) is not a guard: <paramref name="capacity"/> gives the text it becomes, from its name and value.
/// </summary>
internal static string DropGuards(string source, Func<string, string, string> capacity) =>
Regex.Replace(source, @"(?m)^#ifndef (CULTMATH_MAX_\w+)\r?\n#define \1 (\d+)\r?\n#endif[ \t]*$|^[ \t]*#(ifndef|define|endif)\b.*$",
match => match.Groups[1].Success ? capacity(match.Groups[1].Value, match.Groups[2].Value) : string.Empty);
/// <summary>
/// The documented HLSL-to-C# transformations, and nothing else. <paramref name="members"/> is C# source a
/// test supplies, placed first in the class body: it is not a transformation of the HLSL.
/// </summary>
internal static string TransformHlslToCSharp(string hlsl, string members = "")
internal static string TransformHlslToCSharp(string hlsl, string members = "", IReadOnlyDictionary<string, int>? defines = null)
{
// 1. Include guards (#ifndef/#define/#endif) are dropped; C# has no textual include.
var source = Regex.Replace(hlsl, @"(?m)^\s*#(ifndef|define|endif)\b.*$", string.Empty);
// 1. Include guards (#ifndef/#define/#endif) are dropped; C# has no textual include. A capacity macro
// (#ifndef X, #define X n, #endif) becomes `const int X = n;`, or the value a test defines for X.
var source = DropGuards(hlsl, (name, value) => $"const int {name} = {(defines is not null && defines.TryGetValue(name, out var defined) ? defined : value)};");
// 2. Functions taking Texture2D/SamplerState resources are dropped; GPU resources have
// no CultMath analog.
@ -708,14 +775,19 @@ public sealed class HlslSourceCompatibilityTests
source = Regex.Replace(source, @"(?ms)(struct\s+\w+\s*\{)(.*?)(\};)", m =>
m.Groups[1].Value + Regex.Replace(m.Groups[2].Value, @"(?m)^(\s*)(\w+\s+\w+;)", "$1public $2") + m.Groups[3].Value);
// 6. The file body is wrapped in a class; C# has no free functions.
// 6. An array parameter `T name[N]` becomes `T[] name`; the mirror takes the array whole and the count beside it.
source = Regex.Replace(source, @"\b(\w+) (\w+)\[CULTMATH_MAX_\w+\]", "$1[] $2");
// 7. The file body is wrapped in a class; C# has no free functions.
return "using CultMath;\nusing static CultMath.math;\nnamespace CultMathHlsl;\npublic class HlslShader\n{\n" + members + source + "\n}\n";
}
internal static (Assembly? Assembly, IReadOnlyList<string> Errors) CompileShaderMirror(string members = "")
internal static (Assembly? Assembly, IReadOnlyList<string> Errors) CompileShaderMirror(string members = "", IReadOnlyDictionary<string, int>? defines = null, Func<string, string>? mutate = null)
{
var hlsl = ReadShaderSource(FindCultMathRoot());
var tree = CSharpSyntaxTree.ParseText(TransformHlslToCSharp(hlsl, members));
if (mutate is not null)
hlsl = mutate(hlsl);
var tree = CSharpSyntaxTree.ParseText(TransformHlslToCSharp(hlsl, members, defines));
var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
.Split(Path.PathSeparator)
.Append(typeof(math).Assembly.Location)

View file

@ -9,19 +9,19 @@ namespace CultMath.Tests;
/// </summary>
public sealed class LatticeCellsTests
{
private const float Cell = 260.0f;
private const float Falloff = 120.0f;
private const float Drift = 0.7f;
private static readonly float2 Normal = new(0.6f, 0.8f);
internal const float Cell = 260.0f;
internal const float Falloff = 120.0f;
internal const float Drift = 0.7f;
internal static readonly float2 Normal = new(0.6f, 0.8f);
private sealed record Look(float Boost, float Stretch, float Smoothing, float Falloff = LatticeCellsTests.Falloff);
internal sealed record Look(float Boost, float Stretch, float Smoothing, float Falloff = LatticeCellsTests.Falloff);
private static readonly Look[] Looks =
internal static readonly Look[] Looks =
{
new(1.6f, 1.0f, 0.35f), new(1.6f, 2.0f, 0.35f), new(LatticeMaxBoost, LatticeMaxStretch, LatticeMaxSmoothing), new(0.0f, 1.0f, 0.05f),
};
private static float4[] Bodies(float t)
internal static float4[] Bodies(float t)
{
var bodies = new float4[6];
for (var i = 0; i < bodies.Length; i++)
@ -41,7 +41,7 @@ public sealed class LatticeCellsTests
}
// Folds F1, F2, id and smooth over the box of the given radius in absolute row-major order.
private static float4 BoxFold(float2 p, float time, float4[] bodies, Look look, int radius,
internal static float4 BoxFold(float2 p, float time, float4[] bodies, Look look, int radius,
Dictionary<(int, int), float> weights, float2? unitNormal = null)
{
var q = p / Cell;
@ -71,10 +71,10 @@ public sealed class LatticeCellsTests
return new float4(f1, f2, m, cellular_unit(id.w));
}
private static float4 Call(float2 p, float time, float4[] bodies, Look look, float2? normal = null) =>
internal static float4 Call(float2 p, float time, float4[] bodies, Look look, float2? normal = null) =>
weighted_lattice_cells(p, time, bodies, Cell, Drift, look.Boost, look.Falloff, look.Smoothing, normal ?? Normal, look.Stretch);
private static float2 RandomPoint(System.Random r) =>
internal static float2 RandomPoint(System.Random r) =>
new((r.NextSingle() * 2.0f - 1.0f) * 1500.0f, (r.NextSingle() * 2.0f - 1.0f) * 1500.0f);
[Fact]
@ -97,9 +97,9 @@ public sealed class LatticeCellsTests
}
}
private delegate bool Stop(int k, float f1, float f2, Look look);
internal delegate bool Stop(int k, float f1, float f2, Look look);
private static readonly (string name, Stop stop)[] Rules =
internal static readonly (string name, Stop stop)[] Rules =
{
("correct", (k, f1, f2, l) => k > 1 && 0.5f * (k - 1) >= max(f2, f1 + 2.0f * l.Smoothing)),
("cutoff F1 + k", (k, f1, f2, l) => k > 1 && 0.5f * (k - 1) >= max(f2, f1 + l.Smoothing)),
@ -109,7 +109,7 @@ public sealed class LatticeCellsTests
};
// The radius of the box a search with the given exit rule visits (rings 0 to k - 1 when it stops before ring k).
private static int Radius(float2 p, float time, float4[] bodies, Look look, Stop stop,
internal static int Radius(float2 p, float time, float4[] bodies, Look look, Stop stop,
Dictionary<(int, int), float> weights)
{
var q = p / Cell;

View file

@ -93,12 +93,12 @@ public sealed class PhacelleTests
var actual = math.phacelle(p, side, offset, normalization);
var expected = Evaluate(p, side, offset, normalization);
maxError = Math.Max(maxError, Math.Max(Math.Abs(actual.cos.w - expected.Cos), Math.Abs(actual.sin.w - expected.Sin)));
maxError = Math.Max(maxError, Math.Max(Math.Abs(actual.cosine.w - expected.Cos), Math.Abs(actual.sine.w - expected.Sin)));
if (expected.RawLength >= 1.0 - normalization + ThresholdBand)
{
// The output magnitude is 1 wherever the raw magnitude reaches 1 - normalization.
above++;
var magnitude = Math.Sqrt((double)actual.cos.w * actual.cos.w + (double)actual.sin.w * actual.sin.w);
var magnitude = Math.Sqrt((double)actual.cosine.w * actual.cosine.w + (double)actual.sine.w * actual.sine.w);
Assert.True(Math.Abs(magnitude - 1.0) < ValueTolerance, $"magnitude {magnitude} above the threshold at {p}");
}
else if (expected.RawLength <= 1.0 - normalization - ThresholdBand)
@ -130,10 +130,10 @@ public sealed class PhacelleTests
var quarter = math.phacelle(p, side, offset + 0.25f, 0.5f);
var whole = math.phacelle(p, side, offset + 1.0f, 0.5f);
Assert.True(MathF.Abs(quarter.cos.w + basePhasor.sin.w) < 2.0e-5f);
Assert.True(MathF.Abs(quarter.sin.w - basePhasor.cos.w) < 2.0e-5f);
Assert.True(MathF.Abs(whole.cos.w - basePhasor.cos.w) < 2.0e-5f);
Assert.True(MathF.Abs(whole.sin.w - basePhasor.sin.w) < 2.0e-5f);
Assert.True(MathF.Abs(quarter.cosine.w + basePhasor.sine.w) < 2.0e-5f);
Assert.True(MathF.Abs(quarter.sine.w - basePhasor.cosine.w) < 2.0e-5f);
Assert.True(MathF.Abs(whole.cosine.w - basePhasor.cosine.w) < 2.0e-5f);
Assert.True(MathF.Abs(whole.sine.w - basePhasor.sine.w) < 2.0e-5f);
}
}
@ -166,9 +166,9 @@ public sealed class PhacelleTests
if (distance > 0.0) above++; else below++;
var analytic = math.phacelle(p, side, offset, normalization);
var numericCos = CentralDifference(q => math.phacelle(q, side, offset, normalization).cos.w, p, GradientEps);
var numericSin = CentralDifference(q => math.phacelle(q, side, offset, normalization).sin.w, p, GradientEps);
maxError = MathF.Max(maxError, MathF.Max(MaxComponentError(numericCos, analytic.cos), MaxComponentError(numericSin, analytic.sin)));
var numericCos = CentralDifference(q => math.phacelle(q, side, offset, normalization).cosine.w, p, GradientEps);
var numericSin = CentralDifference(q => math.phacelle(q, side, offset, normalization).sine.w, p, GradientEps);
maxError = MathF.Max(maxError, MathF.Max(MaxComponentError(numericCos, analytic.cosine), MaxComponentError(numericSin, analytic.sine)));
}
Assert.True(above >= 150 && below >= 150, $"{above} points above and {below} below the threshold");
@ -199,7 +199,7 @@ public sealed class PhacelleTests
var flow = math.normalize(tangent);
var sideDirection = math.cross(hat, flow);
var phasor = math.phacelle(hat * radiusInCells, sideDirection * math.TAU, 0.25f, 0.5f);
var gradient = new float3(phasor.cos.x, phasor.cos.y, phasor.cos.z);
var gradient = new float3(phasor.cosine.x, phasor.cosine.y, phasor.cosine.z);
alongFlow += Math.Abs(math.dot(gradient, flow));
acrossFlow += Math.Abs(math.dot(gradient, sideDirection));
used++;
@ -232,7 +232,7 @@ public sealed class PhacelleTests
{
var low = math.phacelle(SetAxis(p, axis, GetAxis(p, axis) - steps[k] * 0.5f), side, offset, 0.5f);
var high = math.phacelle(SetAxis(p, axis, GetAxis(p, axis) + steps[k] * 0.5f), side, offset, 0.5f);
maxDifference[k] = MathF.Max(maxDifference[k], MathF.Max(MathF.Abs(low.cos.w - high.cos.w), MathF.Abs(low.sin.w - high.sin.w)));
maxDifference[k] = MathF.Max(maxDifference[k], MathF.Max(MathF.Abs(low.cosine.w - high.cosine.w), MathF.Abs(low.sine.w - high.sine.w)));
}
}
@ -260,8 +260,8 @@ public sealed class PhacelleTests
var pruned = math.phacelle(p, side, offset, normalization);
var unpruned = math.phacelle(p, side, offset, normalization, false);
AssertBitEqual(unpruned.cos, pruned.cos);
AssertBitEqual(unpruned.sin, pruned.sin);
AssertBitEqual(unpruned.cosine, pruned.cosine);
AssertBitEqual(unpruned.sine, pruned.sine);
}
}
@ -325,8 +325,8 @@ public sealed class PhacelleTests
var pruned = math.phacelle(p, PruneCutSide, PruneCutOffset, PruneCutNormalization);
var unpruned = math.phacelle(p, PruneCutSide, PruneCutOffset, PruneCutNormalization, false);
AssertBitEqual(unpruned.cos, pruned.cos);
AssertBitEqual(unpruned.sin, pruned.sin);
AssertBitEqual(unpruned.cosine, pruned.cosine);
AssertBitEqual(unpruned.sine, pruned.sine);
}
}

View file

@ -0,0 +1,160 @@
using CultMath;
using static CultMath.math;
namespace CultMath.Tests;
/// <summary>
/// The shader's site-array functions as C# twins. A shader function cannot take a span, so
/// <c>cultmath_power_border</c>, <c>cultmath_lattice_cell_distance</c> and <c>cultmath_weighted_lattice_cells</c> take
/// <c>float4 sites[CULTMATH_MAX_SITES]</c> and a count; reflection cannot pass a <see cref="ReadOnlySpan{T}"/>, so each
/// twin takes the array and the count and calls the span function on <c>sites[..count]</c>. This table is the one
/// place that maps them, read by the mirror test and by the GLSL fixture. The draws are the cases both run.
/// </summary>
internal static class SiteArrays
{
/// <summary>The shader's default CULTMATH_MAX_SITES; the device families spell their array with it.</summary>
internal const int Capacity = 128;
internal static float4 power_border(float3 p, float4[] sites, int count) => math.power_border(p, sites.AsSpan(0, count));
internal static float lattice_cell_distance(
float2 q, float2 cell, float time, float drift, float4[] bodies, int count, float cellSize, float boost, float falloff,
float2 normal, float stretch) =>
math.lattice_cell_distance(q, cell, time, drift, bodies.AsSpan(0, count), cellSize, boost, falloff, normal, stretch);
internal static float4 weighted_lattice_cells(
float2 p, float time, float4[] bodies, int count, float cellSize, float drift, float boost, float falloff,
float smoothing, float2 stretchNormal, float stretch) =>
math.weighted_lattice_cells(p, time, bodies.AsSpan(0, count), cellSize, drift, boost, falloff, smoothing, stretchNormal, stretch);
// The array the shader receives: the sites, then filler a loop running past the count would read. The filler is
// finite and large, so a loop bound that ignores the count changes the result.
private static float4[] Padded(IReadOnlyList<float4> sites)
{
var array = new float4[Capacity];
for (var i = 0; i < Capacity; i++)
array[i] = i < sites.Count ? sites[i] : new float4(40.0f * (i + 1), -30.0f * (i + 1), 7.0f, 90.0f + i);
return array;
}
private static float Uniform(System.Random r, float lo, float hi) => lo + (hi - lo) * r.NextSingle();
// Counts 0, 1, 6, 16 and the capacity are drawn often; the rest of [0, Capacity] fills between.
private static int Count(System.Random r) => r.Next(3) == 0 ? r.Next(Capacity + 1) : new[] { 0, 1, 6, 16, Capacity }[r.Next(5)];
// Bodies on slow orbits (float4(position.xy, 0, sigma)), spread over the plane the lattice cells cover.
private static float4[] Orbit(System.Random r, int count, float time)
{
var bodies = new List<float4>();
for (var i = 0; i < count; i++)
{
var angle = 0.001f * (i + 1) * time + i;
var radius = 100.0f + 2400.0f * r.NextSingle();
bodies.Add(new float4(radius * MathF.Cos(angle), radius * MathF.Sin(angle), 0.0f, Uniform(r, 20.0f, 300.0f)));
}
return Padded(bodies);
}
internal static object[] PowerBorderArguments(System.Random r)
{
var count = Count(r);
var sites = new List<float4>();
for (var i = 0; i < count; i++)
sites.Add(new float4(Uniform(r, -1500.0f, 1500.0f), Uniform(r, -1500.0f, 1500.0f), Uniform(r, -200.0f, 200.0f), Uniform(r, 10.0f, 300.0f)));
return new object[] { new float3(Uniform(r, -1500.0f, 1500.0f), Uniform(r, -1500.0f, 1500.0f), Uniform(r, -200.0f, 200.0f)), Padded(sites), count };
}
internal static object[] LatticeCellDistanceArguments(System.Random r)
{
var angle = Uniform(r, 0.0f, 6.2831855f);
var look = LatticeCellsTests.Looks[r.Next(LatticeCellsTests.Looks.Length)];
var cell = new float2(r.Next(-300, 300), r.Next(-300, 300));
var count = Count(r);
return new object[]
{
new float2(Uniform(r, -1500.0f, 1500.0f), Uniform(r, -1500.0f, 1500.0f)) / LatticeCellsTests.Cell, cell, Uniform(r, 0.0f, 200.0f), LatticeCellsTests.Drift,
Orbit(r, count, Uniform(r, 0.0f, 200.0f)), count, LatticeCellsTests.Cell, look.Boost, look.Falloff, new float2(MathF.Cos(angle), MathF.Sin(angle)), look.Stretch,
};
}
// The unit-less normals the function normalises itself: ordinary, 3-4-5, tiny, huge, and zero (stretch 1 only).
private static readonly float2[] Normals = { LatticeCellsTests.Normal, new(3.0f, 4.0f), new(3.0e-30f, -4.0e-30f), new(1.0e30f, 2.0e30f), new(0.0f, 0.0f) };
internal static readonly LatticeCellsTests.Look WidestLook = new(LatticeMaxBoost, LatticeMaxStretch, LatticeMaxSmoothing, 1.0e6f);
/// <summary>
/// Points whose ring search runs to ring 8 or past it under the widest look (boost, stretch and smoothing at their
/// maxima, falloff so wide that every cell is boosted), found once by the radius the exit rule visits, and the
/// ring-17 point of LatticeCellsTests.TheCapIsReachedAndMatchesTheFullBox first. Each is (p, time).
/// </summary>
internal static readonly IReadOnlyList<(float2 P, float Time, int Ring)> FarPoints = FindFarPoints();
private static List<(float2, float, int)> FindFarPoints()
{
var found = new List<(float2, float, int)>();
var cap = (new float2(1446.7345f, -680.7637f), 97.69269f);
found.Add((cap.Item1, cap.Item2, LatticeCellsTests.Radius(cap.Item1, cap.Item2, LatticeCellsTests.Bodies(cap.Item2), WidestLook, LatticeCellsTests.Rules[0].stop, new Dictionary<(int, int), float>())));
var r = new System.Random(0x1A77);
while (found.Count < 12)
{
var p = LatticeCellsTests.RandomPoint(r);
var time = 13.0f * r.Next(20);
var ring = LatticeCellsTests.Radius(p, time, LatticeCellsTests.Bodies(time), WidestLook, LatticeCellsTests.Rules[0].stop, new Dictionary<(int, int), float>());
if (ring >= 8)
found.Add((p, time, ring));
}
return found;
}
/// <summary>
/// Points where an exit one ring early (the rule "bound 0.5*k") returns another result than the exact search, found once by
/// running both on the six orbiting bodies under each look: the cases that pin the ring exit to the bit.
/// </summary>
internal static readonly IReadOnlyList<(float2 P, float Time, LatticeCellsTests.Look Look)> ExitBoundPoints = FindExitBoundPoints();
private static List<(float2, float, LatticeCellsTests.Look)> FindExitBoundPoints()
{
var found = new List<(float2, float, LatticeCellsTests.Look)>();
var looks = LatticeCellsTests.Looks.Append(WidestLook).ToArray();
var r = new System.Random(0xE517);
while (found.Count < 24)
{
var p = LatticeCellsTests.RandomPoint(r);
var time = 13.0f * r.Next(20);
var look = looks[r.Next(looks.Length)];
var bodies = LatticeCellsTests.Bodies(time);
var early = LatticeCellsTests.Radius(p, time, bodies, look, LatticeCellsTests.Rules[2].stop, new Dictionary<(int, int), float>());
if (early >= LatticeCellsTests.Radius(p, time, bodies, look, LatticeCellsTests.Rules[0].stop, new Dictionary<(int, int), float>()))
continue;
if (!LatticeCellsTests.BoxFold(p, time, bodies, look, Math.Max(0, early), new Dictionary<(int, int), float>()).Equals(LatticeCellsTests.Call(p, time, bodies, look)))
found.Add((p, time, look));
}
return found;
}
internal static object[] LatticeArguments(System.Random r)
{
switch (r.Next(4))
{
case 0:
var far = FarPoints[r.Next(FarPoints.Count)];
return Lattice(far.P, far.Time, Padded(LatticeCellsTests.Bodies(far.Time)), 6, WidestLook, LatticeCellsTests.Normal);
case 1:
var tight = ExitBoundPoints[r.Next(ExitBoundPoints.Count)];
return Lattice(tight.P, tight.Time, Padded(LatticeCellsTests.Bodies(tight.Time)), 6, tight.Look, LatticeCellsTests.Normal);
}
var time = 13.0f * r.Next(20);
var normal = Normals[r.Next(Normals.Length)];
var look = normal.x == 0.0f ? LatticeCellsTests.Looks[0] : LatticeCellsTests.Looks[r.Next(LatticeCellsTests.Looks.Length)];
var count = Count(r);
return Lattice(LatticeCellsTests.RandomPoint(r), time, Orbit(r, count, time), count, look, normal);
}
private static object[] Lattice(float2 p, float time, float4[] bodies, int count, LatticeCellsTests.Look look, float2 normal) =>
new object[] { p, time, bodies, count, LatticeCellsTests.Cell, LatticeCellsTests.Drift,
look.Boost, look.Falloff, look.Smoothing, normal, look.Stretch };
}

View file

@ -20,14 +20,14 @@ lavapipe-glsl 15 vec2 cultmath_iv_min(vec2, vec2) ulp-bounded 256 0 0 a324b35cb4
lavapipe-glsl 16 vec2 cultmath_iv_max(vec2, vec2) ulp-bounded 256 0 0 d68412262ca234c1da130fc5217ba6856da3e92e435c28c1fbdbf3692a89e1fa
lavapipe-glsl 17 vec2 cultmath_iv_sqr(vec2) ulp-bounded 256 0 0 dbbb0e0df015456393452377d689732428a9a8e24d8e2b407ee9620593b4c46b
lavapipe-glsl 18 vec2 cultmath_iv_sqrt(vec2) ulp-bounded 256 0 0 95bd15d1b283713fc632ced350f5da1b89f8f73e6618329773db1b52acd29991
lavapipe-glsl 19 vec2 cultmath_iv_clamp(vec2, float, float) ulp-bounded 256 0 0 268d61fd6d122e2b85e211c96daf410fb7e2d8ff83ec2082709f640504c852a7
lavapipe-glsl 19 vec2 cultmath_iv_clamp(vec2, float, float) ulp-bounded 256 0 0 abe9daf24930a5e3a49ec994c4cae3a0c27b9d019d6d93ec1963ec3daad664a0
lavapipe-glsl 20 vec2 cultmath_iv_saturate(vec2) ulp-bounded 256 0 0 0f844ef8c4d9a9f748c80677bbbfc36b5f6e682eb87c2d46e7e20635d5f55c26
lavapipe-glsl 21 vec2 cultmath_iv_smoothstep(float, float, vec2) ulp-bounded 256 0 0 3d5944a4c43feb91d6786924e0f5439c05285d28ff1f500b5582893758dd4ce5
lavapipe-glsl 22 vec2 cultmath_iv_lerp(vec2, vec2, float) ulp-bounded 256 0 0 96b09e90a3fbbfe81379a29a23b676e490bc7dc6bb731450197707e6cf4c573f
lavapipe-glsl 23 vec2 cultmath_iv_snoise_ball(vec3, float) ulp-bounded 242 54 0 570eb031260c8f4aab42edc57a5fa1d0da70e8a4e6f0b4bf68208c15506f7cb5
lavapipe-glsl 24 vec2 cultmath_iv_fbm_ball(vec3, float, int, float, float) ulp-bounded 250 56 0 9e7017426314537f84c58dcdd1f85188cbe8f4dd9ababed887f60776cf861ebf
lavapipe-glsl 25 vec4 cultmath_iv_frustum_ball(vec2, float, float, float, float) ulp-bounded 198 1.6457063576593662 0 327c306aa4d06aecbb2681d8808c6dbf0567e87b54a6067c68001e36b3e52169
lavapipe-glsl 26 vec2 cultmath_iv_exp(vec2) ulp-bounded, platform exp 7 7.526057804152758 0 1a46b0fa116f55e5abd70ec9f2a66de0befe8b3577fe472f9eaa98932aae8040
lavapipe-glsl 26 vec2 cultmath_iv_exp(vec2) ulp-bounded, platform exp 7 7.526057804152758 0 b4af8232dc3dd782cc6d790e928ede0f6ba6dfdffd6c71dd9494fb58973e485c
lavapipe-glsl 27 CultPhasor cultmath_phacelle(vec3, vec3, float, float) ulp-bounded, platform exp, sin, cos; across platforms |diff| <= 2^-20 max(|v|, 1); on a device |diff| <= 2^-18 max(|v|, 1) 0 18.5 0 705b4c18027a0838218645e9368c3749ae4b5bf75e0485985725e8e72b73ab88
lavapipe-glsl 28 float cultmath_snoise(vec3) ulp-bounded, on the simplex tie set 127 112 0 6929326e4f27726d7bbdbac3a7823209e2ecfcd301018ef164527c4c1a9f7ae8
lavapipe-glsl 29 vec4 cultmath_snoise_grad(vec3) ulp-bounded, on the simplex tie set 102 546.5 0 6173a812fd4059fa19bce84d34858c6035cdda98aa21a2b210e611cbd58c29d6
@ -36,7 +36,7 @@ lavapipe-glsl 31 vec3 cultmath_af_symbol(float, float) ulp-bounded 256 0 0 04147
lavapipe-glsl 32 vec3 cultmath_af_from_iv(vec2) ulp-bounded 256 0 0 306fe58a3268199fc8fd845a32d956f349a790422fa5c87d5a0824636cb55424
lavapipe-glsl 33 vec2 cultmath_af_range(vec3) ulp-bounded 256 0 0 6245eeba19473d69d0d8155e20e2e6089a6843140723d23ed5d501064023a44a
lavapipe-glsl 34 vec3 cultmath_af_add(vec3, vec3) ulp-bounded 256 0 0 cb1cb3fc69224837342de767525d332687b9d3e17b3de255c28e2f0f65f32640
lavapipe-glsl 35 vec3 cultmath_af_sub(vec3, vec3) ulp-bounded 256 0 0 df44c501a8be20dc46a54faac9583da13f4367adc501a33fe14624f10e54b558
lavapipe-glsl 35 vec3 cultmath_af_sub(vec3, vec3) ulp-bounded 256 0 0 a8fad8ea86cf61841430293fdd9840a54008160250114040380c9634c63bb17e
lavapipe-glsl 36 vec3 cultmath_af_neg(vec3) ulp-bounded 256 0 0 e485acf8ee21c6d880621cc6216b3b2a78318f56f5864b33c5509be27e22a694
lavapipe-glsl 37 vec3 cultmath_af_scale(vec3, float) ulp-bounded 256 0 0 8b0c8a540fea82fe058efc42ffa38dbd69850e579cefe5fdbc4c164b715ec15d
lavapipe-glsl 38 vec3 cultmath_af_add_iv(vec3, vec2) ulp-bounded 256 0 0 620f747e6ba53014c9714a8dbfaa3ff93f281c80938bd035ef64657e3bdc1a16
@ -50,7 +50,7 @@ lavapipe-glsl 45 vec3 cultmath_af_snoise(vec3, vec3, float) ulp-bounded, reach 1
lavapipe-glsl 46 vec3 cultmath_af_fbm(vec3, vec3, float, int, float, float) ulp-bounded, reach 1e-6 to 1e-3 27 102.5 0 42246db4e19a1da20433517cdc7016dda375ea70dc90d1a777679bfd76fb59e8
lavapipe-glsl 47 float cultmath_cycles_divide(float, float) ulp-bounded 256 0 0 87f143c57806ad52c3295cde67e97f31e39c8572b8c0a4b8c577e6fcd8ecbebf
lavapipe-glsl 48 float cultmath_cycles_power(float, float) ulp-bounded, platform pow 136 4.918926113980705 0 0e62db5ea32fdb9fdd3a9b135a914ddadd3231bdee0ff15c709102b26bb89186
lavapipe-glsl 49 float cultmath_cycles_logarithm(float, float) ulp-bounded, platform log 147 4.94622778071426 0 5b2bf1411512c7e5a3f4b335a16fc3ba00ba0784bb87acb14b7d1b9dd551b2f3
lavapipe-glsl 49 float cultmath_cycles_logarithm(float, float) ulp-bounded, platform log 146 4.94622778071426 0 5b2bf1411512c7e5a3f4b335a16fc3ba00ba0784bb87acb14b7d1b9dd551b2f3
lavapipe-glsl 50 float cultmath_cycles_sqrt(float) ulp-bounded 256 0 0 6d4beb34e93ae304b3d9d30f203fb6c2ba222affc0efc35bb5441528e9062a9a
lavapipe-glsl 51 float cultmath_cycles_inverse_sqrt(float) ulp-bounded 256 0 0 71aadd5bd384b9ca2eb89827de53af810a1350b02177de1987e9405e4a6e9ad8
lavapipe-glsl 52 float cultmath_cycles_modulo(float, float) ulp-bounded 256 0 0 cf055cafcb5b2d4789a0a2d5cfbc8bc79f52df646b60f5e47db3f994680949e9
@ -96,16 +96,16 @@ lavapipe-glsl 91 vec3 cultmath_cycles_mix_val(float, vec3, vec3) ulp-bounded 256
lavapipe-glsl 92 vec3 cultmath_cycles_mix_color(float, vec3, vec3) ulp-bounded 256 0 0 00067b1567bcd6c07a81fa5002f145dfca22437a3f3df5389b2665334d14909b
lavapipe-glsl 93 vec3 cultmath_cycles_mix_soft(float, vec3, vec3) ulp-bounded 157 1.9750388068880254 0 ed7c5508ca0543cc20a5f0c5624e75e3ca2a2d6044d481816eeda2edc50c0592
lavapipe-glsl 94 vec3 cultmath_cycles_mix_linear(float, vec3, vec3) ulp-bounded 256 0 0 672435cc30bcbbb253cce7316ee0712b1a18f06924bc89cea2d3388dd164a15d
lavapipe-glsl 95 float cultmath_cycles_arcsine(float) ulp-bounded, platform asin 155 2734.286809678319 0 f28f3ed0a53ffeebdfa84865d2d3d41590e6b46fc1578d40e1215e2a9e1af585
lavapipe-glsl 95 float cultmath_cycles_arcsine(float) ulp-bounded, platform asin 154 2734.286809678319 0 f28f3ed0a53ffeebdfa84865d2d3d41590e6b46fc1578d40e1215e2a9e1af585
lavapipe-glsl 96 float cultmath_cycles_arccosine(float) ulp-bounded, platform acos 79 1046.8674409774287 0 22a2c38ab5a232cdbb33cf0c133362581a398f64a4d0d5d6dd89dc5af06bd890
lavapipe-glsl 97 float cultmath_cycles_arctan2(float, float) ulp-bounded, platform atan 7 25.5 0 2c60447d80186f6dc9726f19c09fa96ec3f66d8f5688968da533d9195ea463c4
lavapipe-glsl 97 float cultmath_cycles_arctan2(float, float) ulp-bounded, platform atan 6 25.5 0 2c60447d80186f6dc9726f19c09fa96ec3f66d8f5688968da533d9195ea463c4
lavapipe-glsl 98 mat3 cultmath_snoise_hessian(vec3) ulp-bounded 30 1358.4375 0 62bbafb8420ed5aaa0ce11d1470ca98945e690b186c33a1e7cf4d71478396465
lavapipe-glsl 99 mat3 cultmath_snoise_hessian(vec3) ulp-bounded, on the simplex tie set 110 1317.4754224467295 0 0efd9b3a637b46033ed0351f64de274db4d9ce05bb83dd1ce57afb3ceaea26bc
lavapipe-glsl 100 float cultmath_integral_clamp_linear(float, float, float, float, float, float) ulp-bounded 256 0 0 84bf9c9a2126b3114a692507cc54f58d313afc33de8a1cac83969cc508433cbe
lavapipe-glsl 101 float cultmath_integral_clamp_linear(float, float, float, float, float, float) ulp-bounded, lo above hi and infinite bounds 256 0 0 ff999094d7794aa525e0f9f9a067aa21408a59559aa546cace3b1bb4a8c7f96d
lavapipe-glsl 102 float cultmath_integral_clamp_linear(float, float, float, float, float, float) ulp-bounded, zero-length spans and flat infinite clamps 256 0 0 c3cc48f4edcac6801b9223f12d289934916431bdfaea8f68af33a1b833ab9860
lavapipe-glsl 103 SpanNoise cultmath_span_fbm(SpanPath, SpanStage, int, float, float) ulp-bounded 7 1012477.3459086476 0 97c531c3a690e056d95e949e243ae74111d4a93a39fd22c0358a103b3bb14bc1
lavapipe-glsl 104 SpanNoise cultmath_span_ridged(SpanPath, SpanStage, int, float, float) ulp-bounded 8 948534.9108393334 0 c5d55a1d05403f6a1a7b7946c3a4c9556277f217fc660d338b3894efbb333e43
lavapipe-glsl 104 SpanNoise cultmath_span_ridged(SpanPath, SpanStage, int, float, float) ulp-bounded 8 948534.9108393334 0 54fd229d69439ff3318690b7802143f8410dc7ece30ad0d56ccc4b68bf39bec2
lavapipe-glsl 105 vec4 cultmath_snoise_grad(vec3) ulp-bounded, coordinate bands 1e-4 to 1e2 116 249.5 0 fff9658a8ec055766c9327ee9b680cf7fb83736c472ae90a11e50d6d2a053fdc
lavapipe-glsl 106 vec4 cultmath_snoise_grad(vec3) ulp-bounded, coordinate bands 1e2 to 4e6 70 11866336.625 0 833484714ebcea59d86e0fbc2eaa4ae247c29541703763989e6758547dbf6db8
lavapipe-glsl 107 mat3 cultmath_snoise_hessian(vec3) ulp-bounded, coordinate bands 1e-4 to 1e2 120 980.7417469278579 0 3ea43dabe0091ea8e994c1dfc65c7274ed38b9343d4d1d06f7caa09c806c91df
@ -117,13 +117,15 @@ lavapipe-glsl 112 vec3 cultmath_segment_closest(vec3, vec3, vec3) ulp-bounded 21
lavapipe-glsl 113 vec4 cultmath_power_border_step(vec3, vec4, vec4, vec4) ulp-bounded 214 43 0 fdfdff73434a146489b002af5e80663658b2dd7b86105a7e65bdab755ab54a5e
lavapipe-glsl 114 vec4 cultmath_power_score(vec3, vec4) ulp-bounded 207 1.4619722193127032 0 dc1aa639ae1dc44003a0f5d8c7c99b2e3d7f51b158c96b3c3bd7a9f7df6d927e
lavapipe-glsl 115 vec4 cultmath_multiplicative_score(vec3, vec4) ulp-bounded 234 1.5529504197086745 0 ee1af26ac82489e8d363e7a6b6b59da7dacfb397d4bc9a723dd68607a69da41a
lavapipe-glsl 116 CultSiteCells cultmath_site_cells_insert(CultSiteCells, vec4, int) ulp-bounded 256 0 0 b8687b139aaa865e9f28fbc84fe37dd4a1d4873d5ff8e2074922c07f56551576
lavapipe-glsl 116 CultSiteCells cultmath_site_cells_insert(CultSiteCells, vec4, int) ulp-bounded 256 0 0 bfad8b369011cfbf14644fec68ce65620694fcbf3cd3804f54953d55587313fc
lavapipe-glsl 117 vec4 cultmath_site_cells_border(CultSiteCells) ulp-bounded 228 1.069496760346883 0 d7b478bef7e6f80902d0b5b255cb4fca30757ff6b9d25dac2030b037a8d342ce
lavapipe-glsl 118 float cultmath_gabriel_margin(vec3, vec3, vec3) ulp-bounded 201 2 0 6a80fbef486de496a08b7c8e7f9f65e2cc0f5b9f93abe50c43e84ad88d1ed034
lavapipe-glsl 119 vec3 cultmath_lattice_site(vec2, float, float) ulp-bounded 256 0 0 84df739c336796638dcf0a99c328a4b8c150c25d80e02248d4b6d023dc13161f
lavapipe-glsl 120 float cultmath_lattice_weight(float, float, float, float) ulp-bounded 231 2.0671110200639147 0 434cdf5603abc2942aa624b9d2e98d5f1231706c86180a80439bc8b72bdd066e
lavapipe-glsl 121 float cultmath_lattice_distance(vec2, vec2, float, vec2, float) ulp-bounded 256 0 0 622d0e1ea0fcf0e2700558dbdaea92b8ce02e44048dbf35944c23b5088c3e40c
lavapipe-glsl 122 float cultmath_lattice_smooth_step(float, float, float, float) ulp-bounded 256 0 0 e6c9aeb52c5e63ebf3c8525dd39b5b88d5f890fd4f23f4894a92232807a3d94e
lavapipe-glsl 121 float cultmath_lattice_distance(vec2, vec2, float, vec2, float) ulp-bounded 256 0 0 ba3fa3bdb4f5f5b2478a4ddfb2e08f2f425e997778a716884e43b13d6ed050b5
lavapipe-glsl 122 float cultmath_lattice_smooth_step(float, float, float, float) ulp-bounded 256 0 0 6cbeddde536b12e83873f2e5ac60d16bef22049d2a7dea61af0ff1e0d5b84740
lavapipe-glsl 123 vec4 cultmath_power_border(vec3, vec4[128], int) ulp-bounded 188 65.72121683891783 0 8427c2b99109a4f59ff745fdb87f261deb1cbae34ce99bc1ae05ebb7f9963b4d
lavapipe-glsl 124 vec4 cultmath_weighted_lattice_cells(vec2, float, vec4[128], int, float, float, float, float, float, vec2, float) ulp-bounded 179 3 0 fd3b58c4e2ba8a7bf801a1e6cdff82468abcb368442bc98b2583fda9d2e6f823
lavapipe-hlsl 0 float cultmath_snoise(vec3) ulp-bounded 94 122.6875 0 5d41c14241a13124f2e1b49f230f5df0c48d0d69fb1be9ef2f33dc1702bf0b0a
lavapipe-hlsl 1 float cultmath_snoise(vec2) ulp-bounded 204 1 0 81d9c62726afdccbbe4ffef173b5c92731eb6bf5927b3979d653e50dbd5d4064
lavapipe-hlsl 2 vec4 cultmath_snoise_grad(vec3) ulp-bounded 71 617.5 0 f7e9ed1707cdb015efaba0e0150193309da0d4c125349518f8b7c60a17b25790
@ -143,14 +145,14 @@ lavapipe-hlsl 15 vec2 cultmath_iv_min(vec2, vec2) ulp-bounded 256 0 0 a324b35cb4
lavapipe-hlsl 16 vec2 cultmath_iv_max(vec2, vec2) ulp-bounded 256 0 0 d68412262ca234c1da130fc5217ba6856da3e92e435c28c1fbdbf3692a89e1fa
lavapipe-hlsl 17 vec2 cultmath_iv_sqr(vec2) ulp-bounded 256 0 0 dbbb0e0df015456393452377d689732428a9a8e24d8e2b407ee9620593b4c46b
lavapipe-hlsl 18 vec2 cultmath_iv_sqrt(vec2) ulp-bounded 256 0 0 95bd15d1b283713fc632ced350f5da1b89f8f73e6618329773db1b52acd29991
lavapipe-hlsl 19 vec2 cultmath_iv_clamp(vec2, float, float) ulp-bounded 256 0 0 268d61fd6d122e2b85e211c96daf410fb7e2d8ff83ec2082709f640504c852a7
lavapipe-hlsl 19 vec2 cultmath_iv_clamp(vec2, float, float) ulp-bounded 256 0 0 abe9daf24930a5e3a49ec994c4cae3a0c27b9d019d6d93ec1963ec3daad664a0
lavapipe-hlsl 20 vec2 cultmath_iv_saturate(vec2) ulp-bounded 256 0 0 0f844ef8c4d9a9f748c80677bbbfc36b5f6e682eb87c2d46e7e20635d5f55c26
lavapipe-hlsl 21 vec2 cultmath_iv_smoothstep(float, float, vec2) ulp-bounded 256 0 0 3d5944a4c43feb91d6786924e0f5439c05285d28ff1f500b5582893758dd4ce5
lavapipe-hlsl 22 vec2 cultmath_iv_lerp(vec2, vec2, float) ulp-bounded 256 0 0 96b09e90a3fbbfe81379a29a23b676e490bc7dc6bb731450197707e6cf4c573f
lavapipe-hlsl 23 vec2 cultmath_iv_snoise_ball(vec3, float) ulp-bounded 244 54 0 f36530a4cd8c9fdc1c73e7ddb6782cd11cbf8493e2ee7b0656e818812504267e
lavapipe-hlsl 24 vec2 cultmath_iv_fbm_ball(vec3, float, int, float, float) ulp-bounded 252 75 0 a1677ea42ce91a5d2b6f83fa7c44879b7a184ed4631108d7a1c6e710bf26ff02
lavapipe-hlsl 25 vec4 cultmath_iv_frustum_ball(vec2, float, float, float, float) ulp-bounded 198 1.6457063576593662 0 327c306aa4d06aecbb2681d8808c6dbf0567e87b54a6067c68001e36b3e52169
lavapipe-hlsl 26 vec2 cultmath_iv_exp(vec2) ulp-bounded, platform exp 7 7.526057804152758 0 1a46b0fa116f55e5abd70ec9f2a66de0befe8b3577fe472f9eaa98932aae8040
lavapipe-hlsl 26 vec2 cultmath_iv_exp(vec2) ulp-bounded, platform exp 7 7.526057804152758 0 b4af8232dc3dd782cc6d790e928ede0f6ba6dfdffd6c71dd9494fb58973e485c
lavapipe-hlsl 27 CultPhasor cultmath_phacelle(vec3, vec3, float, float) ulp-bounded, platform exp, sin, cos; across platforms |diff| <= 2^-20 max(|v|, 1); on a device |diff| <= 2^-18 max(|v|, 1) 0 18.5 0 705b4c18027a0838218645e9368c3749ae4b5bf75e0485985725e8e72b73ab88
lavapipe-hlsl 28 float cultmath_snoise(vec3) ulp-bounded, on the simplex tie set 144 99 0 be76512b18eb678194dad0c4726c0aef9ba34012b60b79d24c2df27989707145
lavapipe-hlsl 29 vec4 cultmath_snoise_grad(vec3) ulp-bounded, on the simplex tie set 111 546.5 0 124d4be07cbb66edab644e649c081cb7c8be5d99b6c1d84fec5d7ef65c76ed0f
@ -159,7 +161,7 @@ lavapipe-hlsl 31 vec3 cultmath_af_symbol(float, float) ulp-bounded 256 0 0 04147
lavapipe-hlsl 32 vec3 cultmath_af_from_iv(vec2) ulp-bounded 256 0 0 306fe58a3268199fc8fd845a32d956f349a790422fa5c87d5a0824636cb55424
lavapipe-hlsl 33 vec2 cultmath_af_range(vec3) ulp-bounded 256 0 0 6245eeba19473d69d0d8155e20e2e6089a6843140723d23ed5d501064023a44a
lavapipe-hlsl 34 vec3 cultmath_af_add(vec3, vec3) ulp-bounded 256 0 0 cb1cb3fc69224837342de767525d332687b9d3e17b3de255c28e2f0f65f32640
lavapipe-hlsl 35 vec3 cultmath_af_sub(vec3, vec3) ulp-bounded 256 0 0 df44c501a8be20dc46a54faac9583da13f4367adc501a33fe14624f10e54b558
lavapipe-hlsl 35 vec3 cultmath_af_sub(vec3, vec3) ulp-bounded 256 0 0 a8fad8ea86cf61841430293fdd9840a54008160250114040380c9634c63bb17e
lavapipe-hlsl 36 vec3 cultmath_af_neg(vec3) ulp-bounded 256 0 0 e485acf8ee21c6d880621cc6216b3b2a78318f56f5864b33c5509be27e22a694
lavapipe-hlsl 37 vec3 cultmath_af_scale(vec3, float) ulp-bounded 256 0 0 8b0c8a540fea82fe058efc42ffa38dbd69850e579cefe5fdbc4c164b715ec15d
lavapipe-hlsl 38 vec3 cultmath_af_add_iv(vec3, vec2) ulp-bounded 256 0 0 620f747e6ba53014c9714a8dbfaa3ff93f281c80938bd035ef64657e3bdc1a16
@ -173,7 +175,7 @@ lavapipe-hlsl 45 vec3 cultmath_af_snoise(vec3, vec3, float) ulp-bounded, reach 1
lavapipe-hlsl 46 vec3 cultmath_af_fbm(vec3, vec3, float, int, float, float) ulp-bounded, reach 1e-6 to 1e-3 24 122.75 0 7f941b1f0c76d9f2121027a555ae7f2a741cc164402f0df75237198ac97f850b
lavapipe-hlsl 47 float cultmath_cycles_divide(float, float) ulp-bounded 256 0 0 87f143c57806ad52c3295cde67e97f31e39c8572b8c0a4b8c577e6fcd8ecbebf
lavapipe-hlsl 48 float cultmath_cycles_power(float, float) ulp-bounded, platform pow 136 4.918926113980705 0 0e62db5ea32fdb9fdd3a9b135a914ddadd3231bdee0ff15c709102b26bb89186
lavapipe-hlsl 49 float cultmath_cycles_logarithm(float, float) ulp-bounded, platform log 147 4.94622778071426 0 5b2bf1411512c7e5a3f4b335a16fc3ba00ba0784bb87acb14b7d1b9dd551b2f3
lavapipe-hlsl 49 float cultmath_cycles_logarithm(float, float) ulp-bounded, platform log 146 4.94622778071426 0 5b2bf1411512c7e5a3f4b335a16fc3ba00ba0784bb87acb14b7d1b9dd551b2f3
lavapipe-hlsl 50 float cultmath_cycles_sqrt(float) ulp-bounded 256 0 0 6d4beb34e93ae304b3d9d30f203fb6c2ba222affc0efc35bb5441528e9062a9a
lavapipe-hlsl 51 float cultmath_cycles_inverse_sqrt(float) ulp-bounded 256 0 0 71aadd5bd384b9ca2eb89827de53af810a1350b02177de1987e9405e4a6e9ad8
lavapipe-hlsl 52 float cultmath_cycles_modulo(float, float) ulp-bounded 256 0 0 cf055cafcb5b2d4789a0a2d5cfbc8bc79f52df646b60f5e47db3f994680949e9
@ -219,16 +221,16 @@ lavapipe-hlsl 91 vec3 cultmath_cycles_mix_val(float, vec3, vec3) ulp-bounded 256
lavapipe-hlsl 92 vec3 cultmath_cycles_mix_color(float, vec3, vec3) ulp-bounded 256 0 0 00067b1567bcd6c07a81fa5002f145dfca22437a3f3df5389b2665334d14909b
lavapipe-hlsl 93 vec3 cultmath_cycles_mix_soft(float, vec3, vec3) ulp-bounded 157 1.9750388068880254 0 ed7c5508ca0543cc20a5f0c5624e75e3ca2a2d6044d481816eeda2edc50c0592
lavapipe-hlsl 94 vec3 cultmath_cycles_mix_linear(float, vec3, vec3) ulp-bounded 256 0 0 672435cc30bcbbb253cce7316ee0712b1a18f06924bc89cea2d3388dd164a15d
lavapipe-hlsl 95 float cultmath_cycles_arcsine(float) ulp-bounded, platform asin 155 2734.286809678319 0 f28f3ed0a53ffeebdfa84865d2d3d41590e6b46fc1578d40e1215e2a9e1af585
lavapipe-hlsl 95 float cultmath_cycles_arcsine(float) ulp-bounded, platform asin 154 2734.286809678319 0 f28f3ed0a53ffeebdfa84865d2d3d41590e6b46fc1578d40e1215e2a9e1af585
lavapipe-hlsl 96 float cultmath_cycles_arccosine(float) ulp-bounded, platform acos 79 1046.8674409774287 0 22a2c38ab5a232cdbb33cf0c133362581a398f64a4d0d5d6dd89dc5af06bd890
lavapipe-hlsl 97 float cultmath_cycles_arctan2(float, float) ulp-bounded, platform atan 7 25.5 0 2c60447d80186f6dc9726f19c09fa96ec3f66d8f5688968da533d9195ea463c4
lavapipe-hlsl 97 float cultmath_cycles_arctan2(float, float) ulp-bounded, platform atan 6 25.5 0 2c60447d80186f6dc9726f19c09fa96ec3f66d8f5688968da533d9195ea463c4
lavapipe-hlsl 98 mat3 cultmath_snoise_hessian(vec3) ulp-bounded 33 2377.8799856935966 0 a64853839f394cc7c367ba5e107caa8bb396f07cf202263aa917095dd1c939f7
lavapipe-hlsl 99 mat3 cultmath_snoise_hessian(vec3) ulp-bounded, on the simplex tie set 132 1317.4754224467295 0 9066a49e50731881583ffca16c949171bb61578a1dfd497c54dafe773ab49488
lavapipe-hlsl 100 float cultmath_integral_clamp_linear(float, float, float, float, float, float) ulp-bounded 256 0 0 84bf9c9a2126b3114a692507cc54f58d313afc33de8a1cac83969cc508433cbe
lavapipe-hlsl 101 float cultmath_integral_clamp_linear(float, float, float, float, float, float) ulp-bounded, lo above hi and infinite bounds 256 0 0 ff999094d7794aa525e0f9f9a067aa21408a59559aa546cace3b1bb4a8c7f96d
lavapipe-hlsl 102 float cultmath_integral_clamp_linear(float, float, float, float, float, float) ulp-bounded, zero-length spans and flat infinite clamps 256 0 0 c3cc48f4edcac6801b9223f12d289934916431bdfaea8f68af33a1b833ab9860
lavapipe-hlsl 103 SpanNoise cultmath_span_fbm(SpanPath, SpanStage, int, float, float) ulp-bounded 3 1024946.5723514018 0 f6395b9f2609d914894c35b442719be3cc3e11d07c5f0bae5a8719651e60cd4e
lavapipe-hlsl 104 SpanNoise cultmath_span_ridged(SpanPath, SpanStage, int, float, float) ulp-bounded 7 948534.9108393334 0 bfda3536007373bdbef75a5c7bb63a7192f1c90b082984588f23e5b4eb767867
lavapipe-hlsl 104 SpanNoise cultmath_span_ridged(SpanPath, SpanStage, int, float, float) ulp-bounded 7 948534.9108393334 0 2adfdc4e6c81ee0bc7bb7869c55207f5033b4176104bc36dce1528ae0a5fe39b
lavapipe-hlsl 105 vec4 cultmath_snoise_grad(vec3) ulp-bounded, coordinate bands 1e-4 to 1e2 119 423.1430718408491 0 a94c38d1baa651a6faa510920cbe9d1f9528c0adb863ba6e35d835588ad00a58
lavapipe-hlsl 106 vec4 cultmath_snoise_grad(vec3) ulp-bounded, coordinate bands 1e2 to 4e6 73 13116827.1875 0 9ad0fd577b26aa717534c0693df97ee41bcded0768f442739b98f505e4a2a430
lavapipe-hlsl 107 mat3 cultmath_snoise_hessian(vec3) ulp-bounded, coordinate bands 1e-4 to 1e2 122 980.7417469278579 0 0977fa6f446e4f916a9b938ce24ea32df6aa912dc991c182014d309dc0efb1cb
@ -240,10 +242,12 @@ lavapipe-hlsl 112 vec3 cultmath_segment_closest(vec3, vec3, vec3) ulp-bounded 21
lavapipe-hlsl 113 vec4 cultmath_power_border_step(vec3, vec4, vec4, vec4) ulp-bounded 214 43 0 fdfdff73434a146489b002af5e80663658b2dd7b86105a7e65bdab755ab54a5e
lavapipe-hlsl 114 vec4 cultmath_power_score(vec3, vec4) ulp-bounded 207 1.4619722193127032 0 dc1aa639ae1dc44003a0f5d8c7c99b2e3d7f51b158c96b3c3bd7a9f7df6d927e
lavapipe-hlsl 115 vec4 cultmath_multiplicative_score(vec3, vec4) ulp-bounded 234 1.5529504197086745 0 ee1af26ac82489e8d363e7a6b6b59da7dacfb397d4bc9a723dd68607a69da41a
lavapipe-hlsl 116 CultSiteCells cultmath_site_cells_insert(CultSiteCells, vec4, int) ulp-bounded 256 0 0 b8687b139aaa865e9f28fbc84fe37dd4a1d4873d5ff8e2074922c07f56551576
lavapipe-hlsl 116 CultSiteCells cultmath_site_cells_insert(CultSiteCells, vec4, int) ulp-bounded 256 0 0 bfad8b369011cfbf14644fec68ce65620694fcbf3cd3804f54953d55587313fc
lavapipe-hlsl 117 vec4 cultmath_site_cells_border(CultSiteCells) ulp-bounded 228 1.069496760346883 0 d7b478bef7e6f80902d0b5b255cb4fca30757ff6b9d25dac2030b037a8d342ce
lavapipe-hlsl 118 float cultmath_gabriel_margin(vec3, vec3, vec3) ulp-bounded 201 2 0 6a80fbef486de496a08b7c8e7f9f65e2cc0f5b9f93abe50c43e84ad88d1ed034
lavapipe-hlsl 119 vec3 cultmath_lattice_site(vec2, float, float) ulp-bounded 256 0 0 84df739c336796638dcf0a99c328a4b8c150c25d80e02248d4b6d023dc13161f
lavapipe-hlsl 120 float cultmath_lattice_weight(float, float, float, float) ulp-bounded 231 2.0671110200639147 0 434cdf5603abc2942aa624b9d2e98d5f1231706c86180a80439bc8b72bdd066e
lavapipe-hlsl 121 float cultmath_lattice_distance(vec2, vec2, float, vec2, float) ulp-bounded 256 0 0 622d0e1ea0fcf0e2700558dbdaea92b8ce02e44048dbf35944c23b5088c3e40c
lavapipe-hlsl 122 float cultmath_lattice_smooth_step(float, float, float, float) ulp-bounded 256 0 0 e6c9aeb52c5e63ebf3c8525dd39b5b88d5f890fd4f23f4894a92232807a3d94e
lavapipe-hlsl 121 float cultmath_lattice_distance(vec2, vec2, float, vec2, float) ulp-bounded 256 0 0 ba3fa3bdb4f5f5b2478a4ddfb2e08f2f425e997778a716884e43b13d6ed050b5
lavapipe-hlsl 122 float cultmath_lattice_smooth_step(float, float, float, float) ulp-bounded 256 0 0 6cbeddde536b12e83873f2e5ac60d16bef22049d2a7dea61af0ff1e0d5b84740
lavapipe-hlsl 123 vec4 cultmath_power_border(vec3, vec4[128], int) ulp-bounded 188 65.72121683891783 0 8427c2b99109a4f59ff745fdb87f261deb1cbae34ce99bc1ae05ebb7f9963b4d
lavapipe-hlsl 124 vec4 cultmath_weighted_lattice_cells(vec2, float, vec4[128], int, float, float, float, float, float, vec2, float) ulp-bounded 179 3 0 fd3b58c4e2ba8a7bf801a1e6cdff82468abcb368442bc98b2583fda9d2e6f823

View file

@ -16,6 +16,7 @@ import sys
HLSL_VEC = re.compile(r'(float|int|uint)([234])$')
GLSL_VEC = re.compile(r'(i|u|)vec([234])$')
KIND = {'float': 'f', 'int': 'i', 'uint': 'u'}
ARRAY = re.compile(r'(\w+)\[(\d+)\]$') # an array leaf, vec4[128]: N elements of one leaf type, N x its words
def structs(root):
@ -79,6 +80,19 @@ def body(sig, n, win, wout, defs, glsl):
ret, name, params = m.group(1), m.group(2), [p.strip() for p in m.group(3).split(',') if p.strip()]
lines, offset, args = [], 0, []
for j, p in enumerate(params):
array = ARRAY.match(p)
if array:
elem, size = array.group(1), int(array.group(2))
words = leaves(elem, defs)
lines.append(' %s a%d[%d];' % (elem if glsl else hlsl_type(elem), j, size))
lines.append(' for (uint e = 0u; e < %du; e++) {' % size)
for k, leaf in enumerate(words):
word = ('In.a[b + %du + e * %du]' if glsl else 'In[b + %du + e * %du]') % (offset + k, len(words))
lines.append(' a%d[e]%s = %s;' % (j, access(leaf, glsl), read(leaf[1], glsl, word)))
lines.append(' }')
offset += size * len(words)
args.append('a%d' % j)
continue
ptype = p if glsl else hlsl_type(p)
lines.append(' %s a%d;' % (ptype, j))
for leaf in leaves(p, defs):

View file

@ -5,6 +5,15 @@ All notable changes to this package are documented in this file.
Earlier releases (0.2.0-0.2.3) predate this package's changelog and are not
backfilled here; see `docs/semver-policy.md` for why.
## [Unreleased]
### Breaking
- `CultPhasor.cos` and `.sin` are now `.cosine` and `.sine` in C#, HLSL and GLSL,
constructor parameters included, because SPIRV-Cross renames members that match
GLSL builtins. This is a minor bump on 0.y.z, so the next cultmath-unity release
is 0.5.0.
## [0.4.1]
### Fixed

View file

@ -14,8 +14,8 @@
// against CultMath.CultPhasor, same field order.
struct CultPhasor
{
float4 cos;
float4 sin;
float4 cosine;
float4 sine;
};
// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave
@ -84,13 +84,13 @@ CultPhasor cultmath_phacelle(float3 p, float3 side, float offset, float normaliz
float outCos = rawCos / rawLength;
float outSin = rawSin / rawLength;
float3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
result.cos = float4((gradRawCos - outCos * gradLength) / rawLength, outCos);
result.sin = float4((gradRawSin - outSin * gradLength) / rawLength, outSin);
result.cosine = float4((gradRawCos - outCos * gradLength) / rawLength, outCos);
result.sine = float4((gradRawSin - outSin * gradLength) / rawLength, outSin);
}
else
{
result.cos = float4(gradRawCos / floorLength, rawCos / floorLength);
result.sin = float4(gradRawSin / floorLength, rawSin / floorLength);
result.cosine = float4(gradRawCos / floorLength, rawCos / floorLength);
result.sine = float4(gradRawSin / floorLength, rawSin / floorLength);
}
return result;

View file

@ -600,14 +600,12 @@ CultCellular cultmath_cellular(float3 p)
return result;
}
// Sites: a site is float4(position.xyz, weight). A span is a loop over a per-site step, because a shader
// function cannot take a span in the mirror (design.md, "Sites"). The consumer's loop, over its own buffer:
// Sites: a site is float4(position.xyz, weight). A shader function cannot take a span (design.md, "Sites"), so a span is a
// fixed-capacity array and a count (cultmath_power_border, below), or a loop over a per-site step:
// CultSiteCells cells = cultmath_site_cells_empty();
// for (int i = 0; i < count; i++)
// cells = cultmath_site_cells_insert(cells, cultmath_power_score(p, sites[i]), i);
// The exact power cell border is a second loop over the buffer, skipping the owner cells.nearestSite:
// float4 border = float4(0.0, 0.0, 0.0, 1.0e30); int owner = (int)cells.nearestSite;
// for (int k = 0; k < count; k++) if (k != owner) border = cultmath_power_border_step(p, sites[owner], sites[k], border);
// The exact power cell border is cultmath_power_border, the second loop over the sites that skips the owner cells.nearestSite.
// The step trusts the owner: a p outside the owner's cell reports distance 0 with that plane's normal, not an error.
// Statement for statement with math.Sites.cs (power_score, multiplicative_score, site_cells_*, gabriel_margin).
struct CultSiteCells
@ -688,21 +686,8 @@ float cultmath_gabriel_margin(float3 a, float3 b, float3 other)
return (cultmath_distance(other, (a + b) * 0.5) - r) / r;
}
// Weighted lattice cells: the four per-cell steps of math.Lattice.cs, statement for statement. A shader function cannot take a
// span (design.md, "Sites"), so weighted_lattice_cells is the consumer's loop over its own body buffer, in two passes:
// float2 q = p / cellSize; int2 c0 = (int2)floor(q); float f1 = 1.0e30, f2 = 1.0e30; int reach = 0;
// for (int k = 0; ; k++) { // ring pass: Chebyshev rings 0, 1, 2, ... around c0, exact exit
// if (k > 1 && 0.5 * (k - 1) >= max(f2, f1 + 2.0 * smoothing)) break; // k never reaches 38 in the accepted range
// for each cell on ring k: d = cultmath_lattice_distance(q, cultmath_lattice_site(cell, time, drift).xy, w, normal, stretch),
// w = cultmath_lattice_weight(site.z, border, boost, falloff), border = the .w of cultmath_power_border_step
// folded over the bodies (owner = the nearest body of the cell centre (cell + 0.5) * cellSize, z = 0);
// if (d < f1) { f2 = f1; f1 = d; } else if (d < f2) f2 = d; store d for the box pass
// reach = k; }
// float m = f1 + smoothing; // box pass: every stored cell, rows dz = -reach..reach, then dx = -reach..reach
// for each (dz, dx): m = cultmath_lattice_smooth_step(m, d, f1, smoothing); // strict < for the winning id (earlier cell wins ties)
// The preconditions the C# function throws for are the caller's in a shader: finite arguments, cellSize > 0, boost in [0, 4],
// falloff > 0, smoothing in (0, 1], stretch in [1, 2], p within 2^23 cells. The normal is normalised as C# does: divide by its
// largest component, then by its length (a zero normal means (1, 0) and needs stretch == 1). The ring bound is 37.
// Weighted lattice cells: the four per-cell steps of math.Lattice.cs, statement for statement; cultmath_weighted_lattice_cells,
// after them, is the whole function.
float3 cultmath_lattice_site(float2 cell, float time, float drift)
{
int3 h = cultmath_pcg3d(int3((int)cell.x, (int)cell.y, 0));
@ -734,6 +719,90 @@ float cultmath_lattice_smooth_step(float m, float d, float f1, float smoothing)
return min(next, f1 + smoothing);
}
// Sites by value: a shader function cannot take a span, so these take float4 sites[CULTMATH_MAX_SITES] and the count in use, with
// count in [0, CULTMATH_MAX_SITES]. A consumer defines CULTMATH_MAX_SITES before including this file to change the capacity; loops
// run to count, so the capacity costs uniform space, not iterations. A caller whose sites live in a buffer copies them into a local array first.
// The preconditions the C# functions throw for are the caller's in a shader: finite arguments, cellSize > 0, boost in [0, 4],
// falloff > 0, smoothing in (0, 1], stretch in [1, 2], p within 2^23 cells. A zero stretchNormal means (1, 0) and needs stretch == 1.
// cultmath_weighted_lattice_cells is math.weighted_lattice_cells: the ring pass is bounded by k <= 37 and exits as C# does, and the
// box pass recomputes each distance instead of caching it (a 75 x 75 private array is not a shader option), with the same values.
#ifndef CULTMATH_MAX_SITES
#define CULTMATH_MAX_SITES 128
#endif
float4 cultmath_power_border(float3 p, float4 sites[CULTMATH_MAX_SITES], int count)
{
CultSiteCells cells = cultmath_site_cells_empty();
for (int i = 0; i < count; i++)
cells = cultmath_site_cells_insert(cells, cultmath_power_score(p, sites[i]), i);
float4 border = float4(0.0, 0.0, 0.0, 1.0e30);
int owner = (int)cells.nearestSite;
if (owner < 0)
return border;
for (int k = 0; k < count; k++)
if (k != owner)
border = cultmath_power_border_step(p, sites[owner], sites[k], border);
return border;
}
float cultmath_lattice_cell_distance(float2 q, float2 cell, float time, float drift, float4 bodies[CULTMATH_MAX_SITES], int count,
float cellSize, float boost, float falloff, float2 normal, float stretch)
{
float3 site = cultmath_lattice_site(cell, time, drift);
float border = cultmath_power_border(float3((cell + 0.5) * cellSize, 0.0), bodies, count).w;
return cultmath_lattice_distance(q, site.xy, cultmath_lattice_weight(site.z, border, boost, falloff), normal, stretch);
}
float4 cultmath_weighted_lattice_cells(float2 p, float time, float4 bodies[CULTMATH_MAX_SITES], int count, float cellSize, float drift,
float boost, float falloff, float smoothing, float2 stretchNormal, float stretch)
{
float scale = max(abs(stretchNormal.x), abs(stretchNormal.y));
float2 normal = float2(1.0, 0.0);
if (scale > 0.0)
{
normal = stretchNormal / scale;
normal = normal / length(normal);
}
float2 q = p / cellSize;
float2 origin = floor(q);
int cx0 = (int)origin.x;
int cz0 = (int)origin.y;
float f1 = 1.0e30;
float f2 = 1.0e30;
int reach = 0;
for (int k = 0; k <= 37; k++)
{
if (k > 1 && 0.5 * (float)(k - 1) >= max(f2, f1 + 2.0 * smoothing))
break;
for (int dz = -k; dz <= k; dz++)
{
int step = dz == -k || dz == k || k == 0 ? 1 : 2 * k;
for (int dx = -k; dx <= k; dx += step)
{
float d = cultmath_lattice_cell_distance(q, float2(cx0 + dx, cz0 + dz), time, drift, bodies, count, cellSize, boost, falloff, normal, stretch);
if (d < f1) { f2 = f1; f1 = d; }
else if (d < f2) f2 = d;
}
}
reach = k;
}
float m = f1 + smoothing;
float best = 1.0e30;
float second = 1.0e30;
int winDx = 0;
int winDz = 0;
for (int dz = -reach; dz <= reach; dz++)
for (int dx = -reach; dx <= reach; dx++)
{
float d = cultmath_lattice_cell_distance(q, float2(cx0 + dx, cz0 + dz), time, drift, bodies, count, cellSize, boost, falloff, normal, stretch);
if (d < best) { second = best; best = d; winDx = dx; winDz = dz; }
else if (d < second) second = d;
m = cultmath_lattice_smooth_step(m, d, f1, smoothing);
}
int4 id = cultmath_pcg4d(int4(cx0 + winDx, cz0 + winDz, 0, 0));
return float4(best, second, m, (float)((uint)id.w >> 8) * (1.0 / 16777216.0));
}
float2 cultmath_segment_closest(float2 point_value, float2 start, float2 end)
{
float2 segment = end - start;

View file

@ -14,7 +14,7 @@ namespace GameCult.Caching.MessagePack;
/// The canonical System.Text.Json shape of every public CultMath value type, the JSON twin of
/// <see cref="CultMathResolver"/>. Vectors, quaternions and colors are objects keyed by their
/// field names (<c>{"x":1,"y":2}</c>), matrices are arrays of row objects, <c>rect</c> is
/// <c>{"min":..,"max":..}</c>, <c>CultCellular</c> is <c>{"nearest":..,"edge":..,"id":..}</c>, <c>CultPhasor</c> is <c>{"cos":..,"sin":..}</c>, <c>SpanNoise</c> is <c>{"field":..,"curvature":..,"fold":..}</c>, <c>SpanPath</c> is <c>{"midpoint":..,"velocity":..,"speed":..,"acceleration":..,"halfLength":..}</c>, <c>SpanStage</c> is <c>{"frequency":..,"stretch":..,"axis":..,"shift":..,"offset":..,"octaveStep":..}</c>, and <c>CultSiteCells</c> is <c>{"nearest":..,"second":..,"nearestSite":..,"secondSite":..}</c>.
/// <c>{"min":..,"max":..}</c>, <c>CultCellular</c> is <c>{"nearest":..,"edge":..,"id":..}</c>, <c>CultPhasor</c> is <c>{"cosine":..,"sine":..}</c> (reading also accepts the legacy <c>cos</c>/<c>sin</c> names), <c>SpanNoise</c> is <c>{"field":..,"curvature":..,"fold":..}</c>, <c>SpanPath</c> is <c>{"midpoint":..,"velocity":..,"speed":..,"acceleration":..,"halfLength":..}</c>, <c>SpanStage</c> is <c>{"frequency":..,"stretch":..,"axis":..,"shift":..,"offset":..,"octaveStep":..}</c>, and <c>CultSiteCells</c> is <c>{"nearest":..,"second":..,"nearestSite":..,"secondSite":..}</c>.
/// Property names are fixed: <c>PropertyNamingPolicy</c> does not rename them, and
/// <c>PropertyNameCaseInsensitive</c> makes reading case-insensitive.
/// <para>
@ -270,14 +270,24 @@ public static class CultMathJson
{
private readonly string[] names;
private readonly byte[][] utf8Names;
private readonly string?[] legacyNames;
private readonly byte[]?[] legacyUtf8Names;
private readonly JsonEncodedText[] encoded;
private readonly Node<S>[] children;
private readonly int[] offsets;
public Fields(params (string Name, Node<S> Child)[] fields)
: this(Array.ConvertAll(fields, f => (f.Name, f.Child, (string?)null)))
{
}
// A legacy name is matched on read only and maps to the same field as its current name.
public Fields(params (string Name, Node<S> Child, string? Legacy)[] fields)
{
names = new string[fields.Length];
utf8Names = new byte[fields.Length][];
legacyNames = new string?[fields.Length];
legacyUtf8Names = new byte[]?[fields.Length];
encoded = new JsonEncodedText[fields.Length];
children = new Node<S>[fields.Length];
offsets = new int[fields.Length];
@ -285,6 +295,8 @@ public static class CultMathJson
{
names[i] = fields[i].Name;
utf8Names[i] = Encoding.UTF8.GetBytes(fields[i].Name);
legacyNames[i] = fields[i].Legacy;
legacyUtf8Names[i] = fields[i].Legacy is { } legacy ? Encoding.UTF8.GetBytes(legacy) : null;
encoded[i] = JsonEncodedText.Encode(fields[i].Name);
children[i] = fields[i].Child;
offsets[i] = Count;
@ -339,13 +351,14 @@ public static class CultMathJson
if (!ignoreCase)
{
for (var i = 0; i < utf8Names.Length; i++)
if (r.ValueTextEquals(utf8Names[i])) return i;
if (r.ValueTextEquals(utf8Names[i]) || (legacyUtf8Names[i] is { } legacy && r.ValueTextEquals(legacy))) return i;
return -1;
}
var name = r.GetString()!;
for (var i = 0; i < names.Length; i++)
if (string.Equals(names[i], name, StringComparison.OrdinalIgnoreCase)) return i;
if (string.Equals(names[i], name, StringComparison.OrdinalIgnoreCase)
|| (legacyNames[i] is { } legacy && string.Equals(legacy, name, StringComparison.OrdinalIgnoreCase))) return i;
return -1;
}
}
@ -481,7 +494,7 @@ public static class CultMathJson
Add<Random, uint>(UIntScalar.Instance, new Fields<uint>(("state", Val<uint>())),
a => new Random { state = a[0] }, (v, a) => a[0] = v.state),
// Compositions: matrices are arrays of row objects, rect is {min, max}, CultCellular is {nearest, edge, id}, CultPhasor is {cos, sin}, SpanNoise is {field, curvature, fold}, SpanPath is {midpoint, velocity, speed, acceleration, halfLength}, SpanStage is {frequency, stretch, axis, shift, offset, octaveStep}, CultSiteCells is {nearest, second, nearestSite, secondSite}.
// Compositions: matrices are arrays of row objects, rect is {min, max}, CultCellular is {nearest, edge, id}, CultPhasor is {cosine, sine}, SpanNoise is {field, curvature, fold}, SpanPath is {midpoint, velocity, speed, acceleration, halfLength}, SpanStage is {frequency, stretch, axis, shift, offset, octaveStep}, CultSiteCells is {nearest, second, nearestSite, secondSite}.
Add<float2x2, float>(f, new Rows<float>(2, Xy<float>()),
a => new float2x2(a[0], a[1], a[2], a[3]),
(v, a) => { a[0] = v[0].x; a[1] = v[0].y; a[2] = v[1].x; a[3] = v[1].y; }),
@ -507,12 +520,12 @@ public static class CultMathJson
a[4] = v.edge.x; a[5] = v.edge.y; a[6] = v.edge.z; a[7] = v.edge.w;
a[8] = v.id;
}),
Add<CultPhasor, float>(f, new Fields<float>(("cos", Xyzw<float>()), ("sin", Xyzw<float>())),
Add<CultPhasor, float>(f, new Fields<float>(("cosine", Xyzw<float>(), "cos"), ("sine", Xyzw<float>(), "sin")),
a => new CultPhasor(new float4(a[0], a[1], a[2], a[3]), new float4(a[4], a[5], a[6], a[7])),
(v, a) =>
{
a[0] = v.cos.x; a[1] = v.cos.y; a[2] = v.cos.z; a[3] = v.cos.w;
a[4] = v.sin.x; a[5] = v.sin.y; a[6] = v.sin.z; a[7] = v.sin.w;
a[0] = v.cosine.x; a[1] = v.cosine.y; a[2] = v.cosine.z; a[3] = v.cosine.w;
a[4] = v.sine.x; a[5] = v.sine.y; a[6] = v.sine.z; a[7] = v.sine.w;
}),
Add<SpanNoise, float>(f, new Fields<float>(("field", Xyzw<float>()), ("curvature", Val<float>()), ("fold", Val<float>())),
a => new SpanNoise(new float4(a[0], a[1], a[2], a[3]), a[4], a[5]),

View file

@ -180,7 +180,7 @@ public sealed class CultMathResolver : IFormatterResolver
return value;
}),
// Compositions: matrices are rows, rect is [min, max], CultCellular is [nearest, edge, id], CultPhasor is [cos, sin], SpanNoise is [field, curvature, fold], SpanPath is [midpoint, velocity, speed, acceleration, halfLength], SpanStage is [frequency, stretch, axis, shift, offset, octaveStep], CultSiteCells is [nearest, second, nearestSite, secondSite].
// Compositions: matrices are rows, rect is [min, max], CultCellular is [nearest, edge, id], CultPhasor is [cosine, sine], SpanNoise is [field, curvature, fold], SpanPath is [midpoint, velocity, speed, acceleration, halfLength], SpanStage is [frequency, stretch, axis, shift, offset, octaveStep], CultSiteCells is [nearest, second, nearestSite, secondSite].
Shape<float2x2>((ref MessagePackWriter w, float2x2 v) => { w.WriteArrayHeader(2); PutFloat2(ref w, v[0]); PutFloat2(ref w, v[1]); },
(ref MessagePackReader r) =>
{
@ -221,7 +221,7 @@ public sealed class CultMathResolver : IFormatterResolver
}
return value;
}),
Shape<CultPhasor>((ref MessagePackWriter w, CultPhasor v) => { w.WriteArrayHeader(2); PutFloat4(ref w, v.cos); PutFloat4(ref w, v.sin); },
Shape<CultPhasor>((ref MessagePackWriter w, CultPhasor v) => { w.WriteArrayHeader(2); PutFloat4(ref w, v.cosine); PutFloat4(ref w, v.sine); },
(ref MessagePackReader r) =>
{
var value = default(CultPhasor);
@ -229,8 +229,8 @@ public sealed class CultMathResolver : IFormatterResolver
{
switch (i)
{
case 0: value.cos = GetFloat4(ref r); break;
case 1: value.sin = GetFloat4(ref r); break;
case 0: value.cosine = GetFloat4(ref r); break;
case 1: value.sine = GetFloat4(ref r); break;
default: r.Skip(); break;
}
}

View file

@ -101,7 +101,7 @@ broken graph whole (an `ExprGraphException` naming the rule), so a consumer
| `float2x2`, `float3x3` | array of row vectors (row-major) | array of row objects |
| `rect` | `[min, max]` of `float2` | `{"min":{..},"max":{..}}` |
| `CultCellular` | `[nearest, edge, id]` | `{"nearest":{..},"edge":{..},"id":..}` |
| `CultPhasor` | `[cos, sin]` of `float4` | `{"cos":{..},"sin":{..}}` |
| `CultPhasor` | `[cosine, sine]` of `float4` | `{"cosine":{..},"sine":{..}}` (reads legacy `cos`/`sin`) |
| `CultSiteCells` | `[nearest, second, nearestSite, secondSite]` | `{"nearest":{..},"second":{..},"nearestSite":..,"secondSite":..}` |
Floats are MessagePack float32 (`0xCA`), doubles float64 (`0xCB`), integers the

View file

@ -44,7 +44,7 @@ namespace GameCult.Caching.Tests
Json(new CultCellular(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f), 0.25f)).Should().Be(
"""{"nearest":{"x":1,"y":2,"z":3,"w":4},"edge":{"x":5,"y":6,"z":7,"w":8},"id":0.25}""");
Json(new CultPhasor(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f))).Should().Be(
"""{"cos":{"x":1,"y":2,"z":3,"w":4},"sin":{"x":5,"y":6,"z":7,"w":8}}""");
"""{"cosine":{"x":1,"y":2,"z":3,"w":4},"sine":{"x":5,"y":6,"z":7,"w":8}}""");
Json(new SpanNoise(new float4(1f, 2f, 3f, 4f), 0.25f, 0.5f)).Should().Be(
"""{"field":{"x":1,"y":2,"z":3,"w":4},"curvature":0.25,"fold":0.5}""");
Json(new SpanPath { midpoint = new float3(1f, 2f, 3f), velocity = new float3(4f, 5f, 6f), speed = 7f, acceleration = 8f, halfLength = 9f }).Should().Be(
@ -92,6 +92,87 @@ namespace GameCult.Caching.Tests
Parse<float2?>("null").Should().BeNull();
}
[Test]
public void Reading_AcceptsTheLegacyPhasorNames()
{
// The fixture is what the 0.4.1-era encoder wrote: keys cos/sin.
var path = System.IO.Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "cultphasor-json-legacy.json");
var legacy = Parse<CultPhasor[]>(System.IO.File.ReadAllText(path));
var current = new[]
{
new CultPhasor(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f)),
math.phacelle(new float3(0.3f, 1.7f, -2.2f), new float3(6.2831853f, 0f, 0f), 0.25f, 0.5f),
};
legacy.Should().HaveCount(2);
for (var i = 0; i < 2; i++)
{
BitsOf(legacy[i]).Should().Equal(BitsOf(current[i]));
BitsOf(Parse<CultPhasor>(Json(current[i]))).Should().Equal(BitsOf(current[i]));
}
var caseInsensitive = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }.AddCultMathConverters();
BitsOf(JsonSerializer.Deserialize<CultPhasor>("""{"COS":{"x":1,"y":2,"z":3,"w":4},"Sin":{"x":5,"y":6,"z":7,"w":8}}""", caseInsensitive))
.Should().Equal(BitsOf(current[0]));
}
[Test]
public void Reading_RefusesALegacyAndCurrentNameTogether()
{
var property = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties");
Assert.That(property, Is.Not.Null, "the test runtime's System.Text.Json has AllowDuplicateProperties");
var strict = new JsonSerializerOptions().AddCultMathConverters();
property!.SetValue(strict, false);
Action both = () => JsonSerializer.Deserialize<CultPhasor>("""{"cos":{"x":1},"cosine":{"x":2}}""", strict);
both.Should().Throw<JsonException>().WithMessage("*appears more than once*");
Action reversed = () => JsonSerializer.Deserialize<CultPhasor>("""{"sine":{"x":1},"sin":{"x":2}}""", strict);
reversed.Should().Throw<JsonException>().WithMessage("*appears more than once*");
// With duplicates allowed the later property replaces the earlier one, as for any repeated name.
Parse<CultPhasor>("""{"cos":{"x":1,"y":9},"cosine":{"x":2}}""").cosine.Should().Be(new float4(2f, 0f, 0f, 0f));
}
[Test]
public void Reading_AcceptsTheLegacyPhasorNamesUnderDisallow()
{
var options = new JsonSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }.AddCultMathConverters();
var phasor = JsonSerializer.Deserialize<CultPhasor>("""{"cos":{"x":1,"y":2,"z":3,"w":4},"sin":{"x":5,"y":6,"z":7,"w":8}}""", options);
BitsOf(phasor).Should().Equal(BitsOf(new CultPhasor(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f))));
Action unknown = () => JsonSerializer.Deserialize<CultPhasor>("""{"cos":{"x":1},"coss":{"x":2}}""", options);
unknown.Should().Throw<JsonException>().WithMessage("*not a component*");
}
[Test]
public void Reading_TreatsAThirdOccurrenceOfAPhasorNameAsADuplicate()
{
var property = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties");
Assert.That(property, Is.Not.Null, "the test runtime's System.Text.Json has AllowDuplicateProperties");
var strict = new JsonSerializerOptions().AddCultMathConverters();
property!.SetValue(strict, false);
Action thrice = () => JsonSerializer.Deserialize<CultPhasor>("""{"cos":{"x":1},"cosine":{"x":2},"cos":{"x":3}}""", strict);
thrice.Should().Throw<JsonException>().WithMessage("*appears more than once*");
// Allowed, the last occurrence replaces the whole value, including after a third.
Parse<CultPhasor>("""{"cos":{"x":1,"y":9},"cosine":{"x":2},"cos":{"z":3}}""").cosine.Should().Be(new float4(0f, 0f, 3f, 0f));
}
[Test]
public void Reading_SkipsUnknownNamesWhenCaseInsensitive()
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }.AddCultMathConverters();
JsonSerializer.Deserialize<CultPhasor>("""{"extra":[1,2],"COSINE":{"x":3},"coss":{"x":4}}""", options).cosine.x.Should().Be(3f);
JsonSerializer.Deserialize<float2>("""{"X":1,"q":2,"Y":3}""", options).Should().Be(new float2(1f, 3f));
}
[Test]
public void Writing_NeverEmitsLegacyNames()
{
using var document = JsonDocument.Parse(Json(new CultPhasor(new float4(1f, 1f, 1f, 1f), new float4(2f, 2f, 2f, 2f))));
document.RootElement.EnumerateObject().Select(p => p.Name).Should().Equal("cosine", "sine");
}
private static int[] BitsOf(CultPhasor v) => new[] { v.cosine.x, v.cosine.y, v.cosine.z, v.cosine.w, v.sine.x, v.sine.y, v.sine.z, v.sine.w }
.Select(BitConverter.SingleToInt32Bits).ToArray();
[Test]
public void Reading_RefusesShapesThatAreNotTheType()
{

View file

@ -0,0 +1 @@
[{"cos":{"x":1,"y":2,"z":3,"w":4},"sin":{"x":5,"y":6,"z":7,"w":8}},{"cos":{"x":-5.849812,"y":0.051139403,"z":0.14261721,"w":-0.087419346},"sin":{"x":-2.0218675,"y":0.7666904,"z":-0.22879826,"w":0.9240916}}]

View file

@ -6,6 +6,11 @@ All notable changes to this package are documented in this file.
### Breaking
- `CultMathJson` writes `CultPhasor` as `{"cosine":..,"sine":..}` and still reads
the old `cos`/`sin` keys. A reader from before this change does not know the
new keys: with default options it skips them and decodes zeros, and with
`UnmappedMemberHandling.Disallow` it throws. Upgrade readers before writers.
MessagePack bytes are unchanged.
- CultCache refuses an unknown union arm on read and an unnamed union subtype on
write. A payload whose arm key the union does not declare used to decode to
`null` (and the next save wrote the loss back); a value whose type the union