Merge remote-tracking branch 'origin/main' into hands/variants-c2a-main
This commit is contained in:
commit
0f6edfbfbb
72 changed files with 78062 additions and 892 deletions
|
|
@ -583,6 +583,87 @@ all `MIT OR Apache-2.0`; plus `cfg-if`, `libc`, `cc` (build) and `windows-sys` o
|
|||
stacker compiles C at build time, so a C toolchain becomes a build requirement (gcc in the rust image, MSVC on
|
||||
Starfire). cultcache-rs is MIT. Written by hand, the wrapper would be serde_stacker's ~650 lines of forwarding.
|
||||
|
||||
### Body facts, revision 2 (verdict `cut-decode-stack-safety.s1`)
|
||||
|
||||
Imagination pass `imagination-decode-stack-r2`, session `self-2026-10-14-eureka`, on branch
|
||||
`hands/decode-stack-safety` at `2505c0c4`. Probes live under the session scratchpad,
|
||||
`imagination-decode-stack-r2/` (`gen.py`, `gen2.py`, `driver*.rs.in`, jobs `jobP1` to `jobP4`), run on Yggdrasil
|
||||
in the rust image (rustc 1.95.0). None of it is committed. `jobP1` patches a copy of serde_stacker 0.1.14 so
|
||||
each of its five `maybe_grow` calls (`de.rs:476-520`, the wrapper visitor's container entries) records the
|
||||
stack pointer, and reads the door's red zone and segment from the environment. Shapes: `wide` is the committed
|
||||
`Wide32` (32 slots of `[u64; 32]`, 8 KiB inline); `bigN` is 32 slots of an N-array record (32·N KiB inline:
|
||||
32, 64, 128, 256 KiB), each with an `Option<Box<Self>>` slot; `lim` is 31 slots of a 2-array record (15,880 B).
|
||||
A caller is one `#[inline(never)]` function holding the `Result<T>`.
|
||||
|
||||
D10. **The gap between checks is linear in the record's inline size.** Stack used between two consecutive
|
||||
checks, the worst over a 6-level decode on a 1 GiB thread (every gap in a shape is the same value):
|
||||
|
||||
| Shape (inline) | wide (8 KiB) | big4 (32) | big8 (64) | big16 (128) | big32 (256) |
|
||||
|---|---|---|---|---|---|
|
||||
| debug gap | 115 KiB | 451 | 899 | 1,795 | 3,587 |
|
||||
| debug frames before the first check | 131 KiB | 515 | 1,027 | 2,051 | 4,099 |
|
||||
| release gap | 48 KiB | 192 | 384 | 768 | 1,536 |
|
||||
| release frames before the first check | 48 KiB | 192 | 384 | 768 | 1,536 |
|
||||
|
||||
So the gap is 14·S + 3 KiB in debug and 6·S in release, for S = `size_of` of the record entered: the frames
|
||||
between a container check and the next one each hold the record being built or a `Result` of it (rmp-serde's
|
||||
`any_inner`, the wrapper visitor, the derive's `visit_seq` with one `Option` per slot, the seed). D4's 128-144
|
||||
KiB debug gap for `fat` is this line at 8 KiB plus a margin. The stack touched below the deepest check is 6 KiB
|
||||
in debug and 0 in release, so the excursions between checks are the gaps above.
|
||||
|
||||
D11. **Why the thread size decided, in two ways.** (a) Before the first check the decode has already used the
|
||||
second row of D10: `rmp-serde`'s `deserialize_tuple`, `any_inner` and the wrapper's frames sit between
|
||||
`decode_payload` and the wrapper's first `maybe_grow`, and each holds `Result<T>`. A thread smaller than that
|
||||
row aborts whatever the red zone: `wide` aborts on every thread up to 144 KiB in debug. (b) Mid-decode, a check
|
||||
with between R and the gap remaining lets the next gap overrun; after the first grow the positions in each
|
||||
segment are fixed, so which check first grows (set by the thread size) decides, which is the non-monotonic
|
||||
pattern Soul measured. Thread sweeps at the bound (threads from 64 KiB in steps of a 64th of the span, plus
|
||||
1, 1.5, 2, 3, 4 and 64 MiB):
|
||||
|
||||
| Profile, shape | R = 256 KiB | R = gap - 48 KiB | R = gap rounded up + 64 KiB |
|
||||
|---|---|---|---|
|
||||
| debug wide | 6 of 65 sweep threads abort (64-144 KiB); none at 1-64 MiB | all 65; and at 1-4 MiB | 6 (64-144 KiB); none at 1-64 MiB |
|
||||
| debug big4 | 39 of 65; and at 1.5, 2, 3, 4, 64 MiB | 29; and at 2, 64 MiB | 24, the lowest from 64 KiB; none at 1-64 MiB |
|
||||
| release wide | none | 14, spread from 112 to 864 KiB; and at 1, 4 MiB | none |
|
||||
| release big4 | 9 (64-192 KiB) | 18, including 448-480 KiB | 9 (64-192 KiB) |
|
||||
| release big8 | 40; and at 1.5, 2, 3, 64 MiB | 20; and at 1.5 MiB | 17, the lowest from 64 KiB; none at 1-64 MiB |
|
||||
|
||||
With R at least the gap, the abort counts match the threads smaller than the frames before the first check
|
||||
(D10's second row plus the harness) in every shape and profile, big16 and big32 included (segments 4·R); with
|
||||
R below the gap, threads well above those frames abort mid-decode.
|
||||
|
||||
D12. **Checking at the top of the door** (`jobP4`). `decode_payload` patched so its whole body runs inside
|
||||
`stacker::maybe_grow(R, segment, ..)` before the walk and the wrapper, R = 512 KiB, segment 2 MiB. Every
|
||||
thread from 24 KiB to 1 MiB in 8 KiB steps, then 1.5, 2, 3, 4, 8 and 64 MiB:
|
||||
|
||||
| Profile | wide (8 KiB), 1 level | wide, 255 levels | lim (15,880 B), 1 level | lim, 254 levels |
|
||||
|---|---|---|---|---|
|
||||
| debug: smallest thread that decodes | 40 KiB | 40 KiB | 72 KiB | 72 KiB |
|
||||
| release: smallest thread that decodes | 24 KiB | 24 KiB | 40 KiB | 40 KiB |
|
||||
|
||||
No thread at or above the smallest one aborts, in any of the four columns, in either profile. lim's gap is
|
||||
220 KiB in debug and 93 KiB in release (D10's lines give 220 and 93), so R = 512 KiB is 2.3 times the debug
|
||||
gap of a record at 16 KiB. What a thread needs is now the caller's own frames, which hold the returned value
|
||||
and do not depend on the payload's depth.
|
||||
|
||||
D13. **No red zone or placement removes the bound.** Every frame that holds a value of type T sits between the
|
||||
check before T's container and the check inside it, and serde's own impls add frames that hold T before T's
|
||||
first check: `Box<T>`'s `Deserialize` holds the `Result<T>` it boxes, and the `Option` check before it sized
|
||||
nothing larger than a pointer. A per-type red zone (a forked wrapper sized by `size_of::<V::Value>()`, or a
|
||||
grow in the derive sized by `size_of::<Self>()`) still leaves that frame of about one to three T outside any
|
||||
sized check. So the stack a decode needs between checks is bounded only by bounding the inline size of the
|
||||
records it builds. The derive is the one place that sees every record type it produces: a `const` assertion on
|
||||
`size_of::<Self>()` refuses an oversized record at compile time. A field of a type the derive did not produce
|
||||
(a fixed array of arrays, a large tuple, a plain serde struct) behind a `Box` or `Vec` is not reached by it.
|
||||
|
||||
D14. **Build closure and wasm, measured by Soul (`soul-decode-stack-s1` jobD, `cargo tree -e normal,build`
|
||||
at `2505c0c4` against `ae6ef5a8`).** Added on `x86_64-unknown-linux-gnu`: serde_stacker 0.1.14, stacker 0.1.25,
|
||||
psm 0.1.32, cc 1.8.0, find-msvc-tools 0.1.14, shlex 2.0.1, object 0.39.1 (`Apache-2.0 OR MIT`), all
|
||||
`MIT OR Apache-2.0` except ar_archive_writer 0.5.3 (`Apache-2.0 WITH LLVM-exception`, build-only, via psm).
|
||||
On `x86_64-pc-windows-msvc` the same plus libc 0.2.190 and windows-sys 0.61.2 (`MIT OR Apache-2.0`, beside the
|
||||
crate's own windows-sys 0.59). `fs2 0.4` (8 call sites in `lib.rs`, the store's file locks) has no wasi
|
||||
backend, so cultcache-rs does not build for `wasm32-wasip1`; D8 measured serde_stacker in a separate crate.
|
||||
|
||||
### Authority, and why
|
||||
|
||||
The door owns stack safety as it owns the bound: `decode_payload` runs the bound walk, then rmp-serde through
|
||||
|
|
@ -591,8 +672,12 @@ door descend through" is met there, because every derived level is entered throu
|
|||
`maybe_grow` in the derive would be a second owner of the same check, needs a red zone of 128 KiB in release
|
||||
and 256 KiB in debug for fat where the wrapper needs 32 and 144 KiB (D4), and still misses foreign recursive types. The derive is unchanged.
|
||||
|
||||
The thread's size no longer decides whether an accepted payload decodes, on targets where stacker switches
|
||||
stacks. rmp-serde's 1024 counter stays a backstop that never decides. Callers that decode a payload with
|
||||
Revision 2 (D10-D13): the door's first act is a check, so no frame holding T precedes it, and the red zone is
|
||||
set against the gap of the largest record the derive may produce. That size is a public constant the derive
|
||||
asserts at compile time, because nothing at decode time can bound a frame that holds a T before T's own check
|
||||
(D13). The thread's size then decides nothing beyond the caller's own frames, which hold the returned value
|
||||
whatever the payload's depth (D12). Envelope and store decodes enter through the same grown decoder, without
|
||||
the payload walk, so what they accept is unchanged. rmp-serde's 1024 counter stays a backstop that never decides. Callers that decode a payload with
|
||||
`rmp_serde::from_slice` directly bypass the door, the bound and the grow alike; cultnet-rs re-decodes
|
||||
door-checked `rmpv::Value`s that way (follow-up `rs-redecode-outside-door-r2`).
|
||||
|
||||
|
|
|
|||
|
|
@ -1646,3 +1646,289 @@ regenerated GLSL in one merge.
|
|||
- The `0.77` in `packages/cultmath/docs/design.md:376` and `NoiseGradTests.cs:195`
|
||||
is the upstream tie jump and is accurate; it is a different 0.77 from the
|
||||
range-ceiling ratio at line 1347 of this file.
|
||||
|
||||
## Pass 7: the tape unparks (ruling `tape-unparked-for-trophic-graphs`)
|
||||
|
||||
Written by Imagination (agent `imagination-tape-unpark`, session
|
||||
`self-2026-10-14-eureka`) against CultLib `origin/main` `9ec66d77`. The operator,
|
||||
2026-10-14: "Unpark the tape". Her choice (`aetheria-fauna:ruling:trophic-graph-tape-ruled`)
|
||||
scopes tape steps 2 and 4 of `docs/cultmath-tape-target.md`: a graph document, a
|
||||
C# evaluator and an HLSL interpreter. Pruning (step 3) and meshing (step 5) stay
|
||||
where the target sequences them. The first consumer is Aetheria's trophic
|
||||
placement graph (`aetheria-fauna:target:r4`; Aetheria map section r4 at
|
||||
`4354ef2c`, rows B28-B36). `aetheria-fauna:ruling:skein-filament-construction-ruled`
|
||||
picked `skein-web`, so fauna needs all five CultMath gaps that section names.
|
||||
|
||||
The as-built forbidden writer "an evaluator over `CultMath.Expr`" (Pass 5 and
|
||||
the as-built section above) is lifted for the point evaluator and the HLSL
|
||||
interpreter only. Interval and affine evaluation over the graph belong to pruning
|
||||
and stay parked.
|
||||
|
||||
### Body facts (Pass 7)
|
||||
|
||||
| # | Question | Result |
|
||||
|---|---|---|
|
||||
| T1 | Does `CultMath.Expr` exist? | No. `git log --all -S "CultMath.Expr"` in CultLib finds only two map commits (`32ba1bfa`, `4b34b12b`); in Aetheria, nothing. `packages/cultmath/src/CultMath/` has no `Expr` folder. It exists as ruling `material-ir-cultmath-now` and spec `cut-expr-ir.r1` (base `a7966142`), which no report cites. |
|
||||
| T2 | Is the material consumer that shaped `expr-ir` r1 live? | Not now. `aetheria-release:ruling:demo-material-scope` (2026-10-06): demo hulls ship baked maps, "the material graph comes after the demo". The cones, frontiers and HLSL emitter of r1 served incremental per-ship bakes (`materials-baked-per-ship-incremental`) and generated kernels (`material-mod-graphs-same-lowering`). |
|
||||
| T3 | Does `expr-ir` need `cycles-converters`? | Only for `cycles_*` ops. The trophic graph uses none (Aetheria `trophic_probe.py` at `4354ef2c`: arithmetic, smoothstep, exp, hypot, floor, sin, hashing, Voronoi, segment distance). |
|
||||
| T4 | Who owns CultMath values' wire shape? | `GameCult.Caching.MessagePack`: `CultMathResolver` is the canonical MessagePack shape of every public CultMath value type, in the default chain (`CultDocumentMessagePackSerialization.cs:61`). CultMath references nothing (`CultMath.asmdef` `references: []`). |
|
||||
| T5 | What do the coverage tests demand of a new public type? | `CultMathSerializationTests.cs:187-196` and `CultMathJsonTests.cs:93-99,210-218`: every exported non-enum struct in the CultMath assembly has a MessagePack formatter, a JSON converter and a dictionary-key round trip, and `CultMathResolver` formats nothing else. A public struct in `CultMath.Expr` would have to be formatted; a class or enum is not covered, and `CultMathResolver` must not list a class. |
|
||||
| T6 | Can the HLSL mirror cover `out` parameters or buffers? | No. `HlslSourceCompatibilityTests.cs:120-131` builds each argument with `Activator.CreateInstance` over the parameter type, which a by-ref type refuses; `design.md:96-99` drops functions taking GPU resources. So an HLSL twin of `distance_to_segment` needs an `out`-free overload, and the interpreter's buffer reads are outside the mirror. |
|
||||
| T7 | What does the trophic graph need beyond scalar ops? | (a) Stencils: a predator is a mean over 8 taps of its prey at offset positions (`stencil`, B36); in a graph that is input substitution, as Fidget's `remap_xyz`. (b) Site spans: the slack layer and the strands read every well (`well_slack`, `pairs`, `strands`): a per-batch array input. (c) A per-site weight: the lattice node raises each cell's weight by its proximity to the wells (`lattice_web`, `prox`), a function evaluated at the site, not at the query point. |
|
||||
|
||||
### The model (SKILL step 0b)
|
||||
|
||||
| Kind | What names it | What happens to it over time | Who decides |
|
||||
|---|---|---|---|
|
||||
| Expression graph (`ExprGraph`, a sealed immutable class) | Nothing of its own: it is a value. The consumer record that holds it names it (an Aetheria species record's key). Its content hash is derived, for caches only. | Built by the builder or decoded; validated on both paths by one validator; never mutated. An edit builds a new graph. The persisted shape carries a format version; a graph naming an unknown op, a newer format, or a forward operand is refused whole at decode, never partly loaded. | `CultMath.Expr` owns shape, validation and op vocabulary. `GameCult.Caching.MessagePack` owns the wire shape (`CultMathExprResolver`, beside `CultMathResolver`: T4). The consumer's store owns the record, its key and when it changes. Sub-graph references are the consumer store's and are inlined before Build, so the graph stays flat (precedent `aetheria-release:ruling:material-graph-home-modular`). |
|
||||
| Node | Its index in the graph's topological order; an operand references only a lower index. | Exists only inside one graph value; any rebuild may renumber it. The builder hash-conses identical nodes and drops nodes no output reaches, so one expression always builds to one byte string. An editor that needs stable node ids keeps its own map. | The builder. |
|
||||
| Port | Input ports: the operand positions of the op's signature in the op table. Output: a node is one scalar; a multi-result op (cellular's fields) is read only through `Pick(node, k)` nodes and evaluated once. Graph ports: named inputs (vary per point: position, time), named params (uniform per batch, each with a default), named outputs; names unique per kind, `[A-Za-z_][A-Za-z0-9_]*`. | Graph port names are the graph's contract; renaming one makes a new graph. | The author, through the builder; the evaluator binds names to indices once per bind. |
|
||||
| Op vocabulary | A stable string per op: the CultMath function's name, with an arity suffix where overloads differ (`snoise2`, `snoise3`). Written into persisted graphs. | Append-only. A released op's meaning follows its CultMath function under semver (a value change, as the snoise tie, is a Breaking release note); removing an op is a major bump. | `CultMath.Expr`: one op table, read by the C# evaluator and later by the bytecode lowering. No second table. |
|
||||
| Parameter set (bindings) | The consumer's record (fauna: per-species settings); inside it, param name to value. | Edited freely by the consumer. At bind, a missing name takes the graph's default; an unknown name is refused, typed. CultMath persists no parameter-set type. | The consumer's store. |
|
||||
| Site span (input kind) | A span-input name in the graph. | Bound per evaluation batch by the caller (wells per tick); never persisted in the graph. C#: `ReadOnlySpan<float4>`; HLSL: `StructuredBuffer<float4>` plus a count. Read only by span-taking ops; no op produces one. Lands with `expr-trophic-ops` as an append-only format addition. | The caller binds the content; `CultMath.Expr` owns the kind. |
|
||||
| Bytecode (the lowered tape) | (graph content hash, bytecode format version). | Derived on demand, cache-only; never stored as truth or shipped as a document; rebuilt when the graph or CultMath changes. | `CultMath.Expr`'s lowering (the interpreter cut). Its register model is question `tape-register-model`. |
|
||||
| HLSL interpreter source (`shaders/CultMath.Tape.hlsl`) | The file, versioned by the CultMath release tag; it declares the bytecode format version it reads. | Changes only in releases under semver; a format change bumps the version and the lowering together. Copied byte-identical into the Unity package (`UnityPackageShaderIsIdenticalToTheCanonicalShader`). | CultMath. |
|
||||
| Compiled HLSL artifact | Under the interpreter there is no per-graph compiled HLSL. The one compiled artifact is the consumer's compute kernel that includes `CultMath.Tape.hlsl`, named by the consumer's build. | Compiled by the consumer's build (Unity), never generated per graph. Per-graph generated kernels (the r1 emitter) return only with the material line, as a derived artifact pinned against the C# evaluator. | The consumer's build; CultMath owns only the included source. |
|
||||
| Evaluation results | Derived. | Cache-only, the consumer's. | The consumer. |
|
||||
|
||||
Gap (5) of the fauna map, "CultMath-owned parity for a consumer's combined HLSL",
|
||||
changes shape under the tape: a consumer's composition is a graph, not HLSL, so
|
||||
the parity CultMath owns is graph parity, the C# evaluator against the HLSL
|
||||
interpreter on the consumer's own graph. That harness belongs to the interpreter
|
||||
cut. `aetheria-fauna:follow_up:flow-parity-under-cultmath` (FlowField's `Tri3D`)
|
||||
resolves the same way: `Tri3D` becomes a CultMath function and op, with no
|
||||
Aetheria twin.
|
||||
|
||||
### Decisions (defaults, not forks)
|
||||
|
||||
- **`CultMath.Expr` is the tape's graph; there is one IR.** `expr-ir` r1 is
|
||||
superseded by r2, the first tape cut, under the same label: ruling
|
||||
`material-ir-cultmath-now` names the owner and the scalar SSA shape, and the
|
||||
tape ruling consumes that graph. r2 drops what only the material line used
|
||||
(cones, frontiers, the HLSL emitter: T2) and the `cycles-converters`
|
||||
dependency (T3). Those return as lines when their consumers do.
|
||||
- **Scalar SSA**, as ruled: vector arguments are decomposed operands, and
|
||||
multi-result ops are read through `Pick`. Fidget's tapes are scalar too.
|
||||
- **The graph is a class; its wire shape is a resolver beside `CultMathResolver`**
|
||||
(T4, T5). CultMath stays reference-free and the coverage tests stay as they
|
||||
are. No JSON shape in the first cut (JSON is debug and xenos export).
|
||||
- **Stencils are substitution at build time** (T7a), not a loop in the tape.
|
||||
Each stencil level multiplies the graph by its tap count, as B36 measured.
|
||||
- **`cycles-noise` r1 is withdrawn**: base `a7966142`, it waits on the
|
||||
`cycles-converters` merge and is not on this session's frontier. It is a line
|
||||
below.
|
||||
|
||||
### Questions (admitted in the mind)
|
||||
|
||||
- `tape-register-model`: the HLSL interpreter's register model. Gates the
|
||||
interpreter cut only.
|
||||
- `lattice-site-weight-source`: how a weighted lattice node gets a per-site
|
||||
weight (T7c). Gates fauna gap (1) only.
|
||||
|
||||
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
|
||||
megakernel register pressure; no measured interpreter-versus-kernel timing was
|
||||
found) and section 6 (IR placement); Aetheria
|
||||
`docs/research/2026-10-14-trophic-dag-filaments-prior-art.md` (Voronoi,
|
||||
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.
|
||||
|
||||
### Cut sequence (Pass 7; one line each, mapped when about to run)
|
||||
|
||||
Fauna's first cut, `fauna-trophic-habitat`, evaluates the habitat graph in C# at
|
||||
candidates. It needs `expr-ir` r2, gaps (2) and (4) with (3) under the strands,
|
||||
gap (1), and their ops registered (line 5). It does not need the interpreter or
|
||||
gap (5); its presenter does. The critical path is `cycles-converters` merge,
|
||||
then line 3 beside the ruling on `lattice-site-weight-source` and line 4, then
|
||||
line 5; line 1 runs now, off that path.
|
||||
|
||||
1. `expr-ir` r2 (spec admitted): the graph, builder with substitution, decode
|
||||
validation, wire shape, C# evaluator. Touches no file `cycles-converters`
|
||||
touches (new `Expr` folder, `GameCult.Caching.MessagePack`), so it runs on
|
||||
`origin/main` now and does not wait on that merge.
|
||||
2. (No sibling spec.) Gap (3) alone was the candidate. It and every node cut
|
||||
append GLSL fixture families, bump the mirror's compared count and edit
|
||||
`CultMath.hlsl`, and so does `cycles-converters` (`53f4c213`:
|
||||
`GlslMirrorTests.cs`, `HlslSourceCompatibilityTests.cs`, `glsl-parity.json`,
|
||||
`CultMath.hlsl`). Families append after the previous cut's, so these cuts
|
||||
map on the merged head, not beside the branch. Gap (3) folds into line 3,
|
||||
whose strand kernel is its only consumer.
|
||||
3. `trophic-nodes` (CultLib): gap (3), an `out`-free
|
||||
`distance_to_segment(float2, float2, float2)` in C# with its HLSL twin (T6;
|
||||
`point` is reserved in HLSL, so the parameter is `point_value`); gap (2), a
|
||||
weighted diagram over a site span (F1, F2, first-order border distance;
|
||||
multiplicative and power weights); and gap (4), the soft Gabriel gate
|
||||
(computed per tick on the CPU from a site span; the per-point strand kernel
|
||||
over the gated edge span has the HLSL twin), with the span convention of
|
||||
the model page. Waits on the `cycles-converters` merge and its map pass.
|
||||
4. `lattice-weighted-cellular` (CultLib): gap (1), 2D, `pcg3d` cells, zone
|
||||
time, smooth-min blend, a stated weight range sizing its window. Waits on
|
||||
question `lattice-site-weight-source`.
|
||||
5. `expr-trophic-ops` (CultLib): the span input kind and ops for 2-4 in the op
|
||||
table; a CultMath Unity tag for fauna. Waits on 1, 3 and 4. Fauna's first cut
|
||||
waits on this.
|
||||
6. `tape-interpreter` (CultLib): bytecode lowering, `CultMath.Tape.hlsl`, and
|
||||
the graph-parity harness (gap 5): C# evaluator against the interpreter on a
|
||||
graph corpus and on a consumer's graph, dxc compile plus a device run, held
|
||||
to tolerances measured per op under `cycles-kernel-reference-ruled`'s
|
||||
stance. Waits on 1 and `tape-register-model`. Fauna's presenter waits on it.
|
||||
7. `expr-cycles-ops`: `cycles_*` ops, after `cycles-converters` merges.
|
||||
8. `cycles-noise`, re-mapped from the withdrawn r1, after `cycles-converters`
|
||||
merges.
|
||||
9. Material line, when the material graph returns after the demo: cones,
|
||||
frontiers and an HLSL emitter over the same graph.
|
||||
10. `cultmath-unity-math-parity` (follow-up): square, exp2, exp10, log2, log10.
|
||||
11. Pruning (tape step 3) and meshing (step 5): parked as the target says.
|
||||
|
||||
## Pass 8: volumetric prioritized (ruling `volumetric-work-prioritized`)
|
||||
|
||||
Written by Imagination (agent `imagination-volumetric-frontier`, session
|
||||
`self-2026-10-14-eureka`) against CultLib `main` `dfc720e7`. The operator,
|
||||
2026-10-14, relayed by the Aetheria Self: "I want that volumetric work
|
||||
prioritized, since it's a totally different authoring framework for the nebulae
|
||||
and I'll need to build on it to get it up to par". The ruling names three pieces
|
||||
(the interval- and affine-culled march, the site ground, the integration-native
|
||||
noise domain) and leaves their order to this Self. Later the same day she pointed
|
||||
at "the last nebula playground" as having sophisticated noise. Facts on her two
|
||||
playgrounds were extracted by the Aetheria Self's Eyes into
|
||||
`F:\Projects\eureka-handoff-aux-2026-10-14\eyes-nebula-labs\facts.md`.
|
||||
|
||||
### The frontier as found
|
||||
|
||||
| Piece | Record | State |
|
||||
| --- | --- | --- |
|
||||
| Interval ops | `cut-interval-ops.r4`, reports h1-h4, Soul s1-s3 (s3 all Holds) | Merged to main (`fb567472`). Open Soul findings: `cut-interval-ops.s1.lod-weights-never-exercised` (Low), `cut-interval-ops.s1.shipped-void-two-scenes` (Medium, pre-existing; Pass 4 picked the one shipped void, but the finding was never closed), `cut-interval-ops.s3.lodstep-footprint-branch-unpinned` (Low). |
|
||||
| GLSL lowering | `cut-glsl-lowering.r2`, h1-h4, s1-s3 (s3 all Holds) | Merged (`bb583e89`). Follow-ups `iv-exp-gpu-widening`, `glsl-fixture-uncovered` and `glsl-browser-parity-run` are open. |
|
||||
| Affine forms and shaders | `cut-affine-forms.r2` (h1-h3, s1-s3), `cut-affine-shaders.r2` (h1-h3, s1-s3) | Merged (`156782da`). Soul's s3 on affine-forms has one Falsified claim and no open finding; Self carried it as follow-up `gate-detail-scale-sensitivity`. |
|
||||
| Snoise tie | `cut-snoise-tie.r1`, h1-h2, s1 (all Holds) | Merged (`48aadda0`). It re-pinned `SNOISE_LIPSCHITZ` from 10.099261 to 7.9640074 (Pass 6). |
|
||||
| Site ground | No document in the mind. The file-era spec `docs/cultmath-interval-ground-cut-site-ground.r3.spec.json` (repo `GameCult/gamecult-site`, base `535d170`) and the stardust spec | Never run. The gamecult-site `origin/main` `af6e206` has no ground shader and vendors no CultMath GLSL. Follow-ups `site-ground-affine-and-centred-warp`, `site-ground-dense-check-blind-past-body` and `wells-oracle-shortfall-affine-forms` wait on its next revision. No site-ground spec was ever admitted to the mind, so its first spec there is `cut-site-ground.r1`, whatever the file era numbered it. |
|
||||
| Noise domain | Ruling `integration-native-noise-domain`; `docs/research/integration-native-noise-domain.md` (`114bb4f3`, on main) | The probe ran on 2026-10-03. There is no cut. Its source (`DomainProbe/Program.cs`, scratch commit `5ec9f529`, never merged) survived only in a temp scratchpad. It is now copied, with its images and log, to `F:\Projects\eureka-handoff-aux-2026-10-14\imagination-volumetric-frontier\`. |
|
||||
| Operator's playgrounds | Artifacts "Kernel Nebula Lab" (`c09d2762`, version 1791066352) and "Nebula Span Lab" (`be6a9e31`, version 1791074878, the later one) | Facts file above. The Kernel Lab is the probe's kernel cascade in a page and has no noise function. The Span Lab is the "last nebula playground". |
|
||||
|
||||
No volumetric spec is in force without a report, so none is stale on an old
|
||||
base. Ruling `wells-overhead-plus-floor`, which the brief named as in force, is
|
||||
superseded by `affine-wells-contract-cheaper-than-interval`. The next spec does
|
||||
not touch (c), so it cites neither. Target `r1` is stale for this line: its
|
||||
`not_in_scope` still parks affine forms, and no invariant covers the noise
|
||||
domain. The target revision is Self's.
|
||||
|
||||
### The Span Lab is the framework
|
||||
|
||||
The Span Lab's density is a ramp, `ext · clamp((F − s)/B, 0, cap)`. Here `s` is a
|
||||
height pushed down by power brushes. It is then domain-warped twice by octave sums
|
||||
of `snoise`, each plain, ridged (`1 − 2|n|`) or curl, with an anisotropic stretch
|
||||
along a flow axis. The lab's own words: "The span integrator linearises the whole
|
||||
warp chain per span, bounds the error with a curvature bound, and integrates
|
||||
optical depth in closed form". Its bound is `0.5 K h²` from a hierarchical chain of
|
||||
speed, acceleration, Jacobian and second-derivative bounds. Its empty and saturated
|
||||
proofs use the same model, or the later warps' ranges alone.
|
||||
|
||||
That is ruling `integration-native-noise-domain`'s domain, "built for interval
|
||||
integration", made with the noise she likes. It is also the "totally different
|
||||
authoring framework" she will build on. The kernel cascade (the probe and the Kernel
|
||||
Lab) is the earlier exploration, and it stays a line below.
|
||||
|
||||
**Correction to the brief's expectation.** The next cut is not site-ground. The
|
||||
site ground's field is open: the Span Lab framework, the kernel cascade, or r3's
|
||||
marched void. The r3 machinery (the interval pre-pass, the centred warp, the
|
||||
debug-versus-dense readback) is shaped for a marched field. Question
|
||||
`site-ground-field` asks this.
|
||||
|
||||
### `SNOISE_LIPSCHITZ`: 10.099 in the lab, 7.964 in CultMath
|
||||
|
||||
- **Current: 7.9640074.** It is set in `math.Interval.cs:21`, `CultMath.Interval.hlsl`
|
||||
and the generated GLSL. `MeasureLipschitz` (seed 0x11B5) refined 7.2400064, and
|
||||
7.2400064 x 1.10 = 7.9640074. That measurement is on the tied kernel, re-pinned by
|
||||
`cut-snoise-tie` (Pass 6 table; Soul's `cut-snoise-tie.s1` all Holds).
|
||||
- **Stale: 10.099261.** The same measurement on the pre-tie kernel refined 9.181147.
|
||||
On that kernel snoise jumps by 0.21 to 0.72 inside a 1e-4 ball near lattice
|
||||
diagonals, so gradient ascent climbed a near-discontinuity. Pass 6 recorded 926
|
||||
`af_snoise` misses around that spike before the fix and 0 after.
|
||||
- **The lab embeds the pre-tie kernel.** Its `cultmath_snoise` uses
|
||||
`step(x0.yzx, x0.xyz)`, the 0.3.0 cyclic tie (`SPAN` line 244). CultMath's has the
|
||||
three-component Gustavson order (`CultMath.glsl:90`). Its pins
|
||||
`SN_L = 10.099261`, `SN_H = 56.050385` and `SN_T = 540` were measured on that
|
||||
kernel. `SN_T` came from the lab's own hill climb, 487.1 x 1.11.
|
||||
- So the lab's constant agrees with the lab's kernel.
|
||||
- But the kernel is discontinuous, so no derivative bound holds across the tie
|
||||
set. The lab's "proved" span bounds and its bound audit can miss there.
|
||||
- `SN_H` survived the tie fix unchanged (`MeasureHessian`, same witness). `SN_T`
|
||||
has never been measured on the tied kernel.
|
||||
- **Nothing the interval march or the site ground reads is stale.**
|
||||
- CultLib's march and every `iv_*` and `af_*` function read 7.9640074 in all three
|
||||
runtimes.
|
||||
- The site ground has no code.
|
||||
- The file-era specs and Passes 1-5 cite 10.099261 only as history (as-built
|
||||
section).
|
||||
- The stale copy lives only in the lab page. It is recorded as follow-up
|
||||
`span-lab-pre-tie-kernel`.
|
||||
|
||||
### What of the Span Lab belongs in CultMath (the noise domain)
|
||||
|
||||
One owner each, and the one rule each protects. CultMath owns noise primitives and
|
||||
their bounds. The consumer owns its composition: brushes, the ramp, which warps
|
||||
and in what order. The as-built negative "a flow or envelope function in CultMath"
|
||||
stands.
|
||||
|
||||
| Piece (lab) | Owner | Rule it protects | Line |
|
||||
| --- | --- | --- | --- |
|
||||
| `cultmath_snoise_gh`, the analytic Hessian (`makeGH`, string-spliced from the pre-tie grad kernel) | `math.snoise_hessian` in C#, HLSL and GLSL | One snoise kernel. Value, gradient and Hessian come from the same tie order, so no consumer splices its own derivative from an old kernel | Cut `snoise-hessian` (admitted) |
|
||||
| `SN_T`, the third-derivative tensor norm pin | `SNOISE_THIRD` beside `SNOISE_HESSIAN` | One measured pin per derivative order, re-measured with the kernel | Cut `snoise-hessian` |
|
||||
| Closed-form optical depth of a clamped linear ramp over a span (`compField` clamp split) | `math.integral_clamp_linear` | One closed form, with the clamp split at the exact crossings | Cut `snoise-hessian` (generic math; no fork) |
|
||||
| Curl of a three-potential octave sum (`dispVec` mode 2, `curlK`), with its Jacobian from the Hessian | A CultMath curl primitive | Divergence-free by construction. Its bounds come from `L` and `H` | Waits on the curl ruling (not ruled); needs `snoise_hessian` |
|
||||
| Warp-stage octave field: per-component offsets, per-octave offset, anisotropic stretch `Mop`, ridged `1 − 2|n|` with its fold deviation | CultMath stage functions returning value, Jacobian and span bounds | A stage's bounds compose from the noise pins by the chain rule; a consumer never re-derives them | Waits on question `span-chain-shape` |
|
||||
| Span evaluator: linearise a composed chain, the curvature bound `K`, the empty and saturated proofs | Per `span-chain-shape` | One composition rule for bounds | Waits on `span-chain-shape` |
|
||||
| Brushes, the ramp, `ampG`, `brushPoint`, the chain's order, the scroll animation | The consumer (site, Aetheria) | The authored field is the consumer's | Never CultMath |
|
||||
|
||||
`1 − 2|n|` is the affine image `2(1 − |n|) − 1` of `ridged_grad`'s octave term.
|
||||
Adding it as a variant is a naming choice inside the stage-function cut, not a fork.
|
||||
|
||||
### The noise domain and a curl node (not decided: no ruling)
|
||||
|
||||
The Aetheria Self is testing curl of simplex (`snoise_grad`) as a replacement for
|
||||
`Tri3D` flow noise. The Span Lab already has curl as a warp mode. The domain's
|
||||
owner would serve a curl node like this:
|
||||
|
||||
- It is one CultMath primitive: the curl of three offset `snoise` (or octave-sum)
|
||||
potentials from analytic gradients, with the `cultmath_` HLSL twin, the lowered
|
||||
GLSL and a fixture family. Aetheria's flow law and the nebula's warp both call
|
||||
it. Each consumer keeps its own composition and scaling: the lab's
|
||||
`curlK = 0.25/F`, or Aetheria's amplitude and scroll.
|
||||
- Its Jacobian, which the span evaluator and the kernel cascade's per-kernel affine
|
||||
warp both need, is a difference of Hessian entries. That is why `snoise-hessian`
|
||||
comes first, and why the curl node is not in it.
|
||||
- Bounds without new constants:
|
||||
- Each curl component is a difference of two partials, so its magnitude per
|
||||
octave is at most `2 L` times the amplitude.
|
||||
- Its variation over a ball is bounded through `SNOISE_HESSIAN`.
|
||||
- Its curvature, which the span bound needs, is bounded through `SNOISE_THIRD`.
|
||||
- The as-built line "a flow or envelope function in CultMath" was written against
|
||||
the site's flow law. A curl basis is a noise primitive like `snoise_grad`. If the
|
||||
curl node is ruled, the line needs that clarification, not a waiver.
|
||||
|
||||
### Kernel cascade (kept as a line)
|
||||
|
||||
The probe found the cascade exact, and 0.48x the shipped march's cost at 4.2x its
|
||||
msf on the void. But it "cannot be marched", its look is fibres, and its GPU cost
|
||||
is traversal, which is unmeasured. Optical depth through it is a sum of per-kernel
|
||||
segment integrals, so it is order-independent. Additive blending of each kernel's
|
||||
screen footprint would give exact transmittance in WebGL2, with no compute and no
|
||||
sorting. Emission is not order-independent. This is a hypothesis with no probe
|
||||
behind it. The cascade becomes a cut only if `site-ground-field` or the operator's
|
||||
nebula work picks it.
|
||||
|
||||
### Cut sequence (volumetric line)
|
||||
|
||||
1. `snoise-hessian` (spec admitted): `snoise_hessian`, `SNOISE_THIRD` re-measured
|
||||
on the tied kernel, `integral_clamp_linear`, in C#, HLSL and GLSL with fixture
|
||||
families. It touches no `expr-ir` file. Its provenance prose goes in code
|
||||
comments and the README, not `design.md`, which `expr-ir` edits.
|
||||
2. Question `span-chain-shape`, then the warp-stage and span-bound cut it picks.
|
||||
3. The curl primitive, once the operator rules on curl (the Aetheria flow test).
|
||||
4. Question `site-ground-field`, then `cut-site-ground.r1` on the field it picks.
|
||||
The marched option carries all three site follow-ups; the other options
|
||||
withdraw the two marched-only ones.
|
||||
5. The kernel cascade, if picked.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
# CultMath tapes: interval-pruned field evaluation on CPU and GPU
|
||||
|
||||
Status: steps 2-5 (tape, pruning, HLSL interpreter, meshing) are **parked**
|
||||
(2026-10-02; ruling tape-target-unparks-when-asura-stable). The affine-forms
|
||||
Status: steps 2 and 4 (graph, C# evaluator, HLSL interpreter) unparked on
|
||||
2026-10-14 (ruling tape-unparked-for-trophic-graphs, which supersedes
|
||||
tape-target-unparks-when-asura-stable); their model page and cut sequence are
|
||||
`docs/cultmath-interval-ground-cut.md`, "Pass 7". Steps 3 and 5 (pruning,
|
||||
meshing) stay parked. The affine-forms
|
||||
piece was unparked on 2026-10-03 (ruling affine-forms-unparked) and is on main
|
||||
(156782da), alongside step 1's intervals; its map and as-built record are in
|
||||
`docs/cultmath-interval-ground-cut.md`. This note records the direction for the
|
||||
|
|
|
|||
84
docs/research/lattice-site-weight.md
Normal file
84
docs/research/lattice-site-weight.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Facts for `cultmath-tapes:question:lattice-site-weight-source`
|
||||
|
||||
Agent eyes-lattice-site-weight, session self-2026-10-14-eureka, 2026-10-14. Facts only, no recommendation.
|
||||
Marks: [src] source code, [doc] repo doc, [web] web page, [abs] absence found, [probe] measured by the scripts beside this file.
|
||||
The eureka-state view of the question id failed (`view` wants a PipelineRef, not the id string); the question text was taken from the brief.
|
||||
|
||||
## 1. What the fauna probe did (Aetheria 4354ef2c, read via `git show`)
|
||||
|
||||
- [src] `docs/research/2026-10-14-trophic-dag-probe/trophic_probe.py` `lattice_web` (lines 95-119): per lattice cell `(cx,cz)` in a (2*WIN+1)^2 window (WIN=2): jittered site `(c+.5)*LAT` plus a drifting offset; weight `wt = (hash*.5+.5) * (1 + BOOST*prox)`, `prox = max over wells of exp(-(|site - well| / (2.2*.103*r))^2)`, BOOST 1.6; `d = hypot(...) * wt`; F1, F2 and a polynomial smooth-min `m` accumulated in loop order; output `max(soft^2, exp(-((f2-f1)/.10)^2))`. Wells orbit with zone time, so the site weight is a function of time.
|
||||
- [src] The weight is read at the site's lattice position `(cx+.5)*LAT`, not at the query point and not at the drifted site.
|
||||
- [doc] `docs/aetheria-fauna-map.md` B34: largest presence step for `lattice` .124 (8 steps over .1); "not discontinuities: a traced member's web value rises .03 per step for 5 steps, a thin weighted border sweeping a fixed candidate"; window 7x7 changes nothing; boost .6 removes the steps (.047); reading the lattice drift at the member's cycle start removes them at full boost (.011, `cyc.log`).
|
||||
- [doc] Map r4 gap (1): "a 2D weighted lattice cellular with the smooth-min blend (her node) on `pcg3d` cells and zone time"; "The search window follows the weight range ... The CultMath node states the weight range it supports" (map lines 438, 444-446).
|
||||
- [doc] Prior art doc `docs/research/2026-10-14-trophic-dag-filaments-prior-art.md` 1.1.7 [M]: power diagram evaluated pointwise by the same neighbour search "only if the search window is widened with the weight range"; marked "Neither derivation checked against a source".
|
||||
|
||||
## 2. Literature and implementations
|
||||
|
||||
- [web] Wikipedia, Weighted Voronoi diagram (https://en.wikipedia.org/wiki/Weighted_Voronoi_diagram): multiplicative = distance divided by positive site weight (circular Dirichlet tessellation, edges circular arcs and line segments); additive = weight subtracted from distance (hyperbolic Dirichlet tessellation, hyperbola arcs and segments); power diagram = weight subtracted from squared distance. A multiplicatively weighted cell may be non-convex, disconnected or hold holes. The page says nothing on continuity.
|
||||
- [web] Sangil Lee, "Understanding the Variations of Cellular Noise" (https://sangillee.com/_posts/2025-04-18-Cellular-noises/): weighted Voronoi as effective distance `(1/w)*|x - seed|` with `w = 0.2 + 0.8 * random3(cell).z`, i.e. the per-site weight is a component of the hash of the lattice cell id, computed inside the noise function; the weighted example loops 5x5 instead of 3x3 (no reason given).
|
||||
- [web] Inigo Quilez, Voronoise (https://iquilezles.org/articles/voronoise/): `hash3(cell id)` yields jitter in `.xy` and a per-cell value in `.z`; 5x5 window; notes `min()` is discontinuous and the smooth blend fixes that. No weighted variant.
|
||||
- [web] Worley noise, Wikipedia and The Book of Shaders (https://thebookofshaders.com/12/): the standard is current cell plus neighbours (3x3), with a wider pass for exact borders; Worley's original used a variable number of points per cell. Nothing found on per-point weight attributes beyond the Lee article; the continuity guidance in the search results was general-knowledge reasoning, not sourced.
|
||||
- [abs] No source found stating a continuity theorem for weighted Worley; no source found for a node that takes its per-site weight from an external field function. The only per-site weight mechanism found in shader writing is the hash of the cell id.
|
||||
- [probe] Continuity as observed (section 4): F1 stays continuous as long as the window contains the true winner; failure occurs when it does not.
|
||||
|
||||
## 3. CultMath today
|
||||
|
||||
- [src] `F:\Projects\CultLib\packages\cultmath\src\CultMath\math.cs:909` `public static CultCellular cellular(float3 p)`: one argument, 3D, unweighted; sites `neighbor + jitter` with jitter from `pcg3d(int3(neighbor))` through `cellular_unit`; fixed 5x5x5 window with a per-offset lower-bound prune (`max(0,|d|-1)`), whose exactness proof (comment above the function) relies on the jitter being in [0,1) and on equal weights; returns `CultCellular { nearest = (grad F1, F1), edge = (grad(F2-F1), F2-F1), id = pcg4d of the winning cell mapped to [0,1) }` (`CultCellular.cs`). Hash is of the integer cell, never the float bits.
|
||||
- [src] `math.cs:831-847` `smin_grad(float4 a, float4 b, float k)`: quadratic smooth-min with analytic gradient (value-and-gradient form).
|
||||
- [src] `Voronoi.cs` (`Voronoi.SampleTones`, native `cultmath_apollonian_voronoi_tones`) is a UI-tone colour sampler over a hash-jittered field with a hard-coded 5x5 loop; takes x, y, tone, span; not a weighted-cell function.
|
||||
- [abs] No function in CultMath takes a weight, a callback, or a site span; no weighted cellular, no per-site hash output exposed besides `id`. (Map B28 states the same.) HLSL twin at `shaders/CultMath.hlsl:440-503`.
|
||||
- [doc] `packages/cultmath/docs/design.md` ~265-340: `cellular` section, search-radius proof and pruned/unpruned test pin.
|
||||
- Fact for composition: `CultCellular.id` is already a per-cell hash scalar available to the caller after the call, but only for the winning cell, after F1/F2 are decided.
|
||||
|
||||
## 4. Probe: where does the weight come from, 2D, 4 orbiting wells
|
||||
|
||||
Scripts: `siteweight_probe.py`, `siteweight_probe2.py`; raw numbers `probe_results.json`, `probe_results2.json`. numpy, scratch; hash is a lowbias32-style integer hash (stand-in for pcg3d). Units: lattice cell = 1; domain [1,7]^2, 500x500 grid (600 for shape rows); wells with radii .6-1.2 cells orbiting a centre; weight `wt = base(.5..1) * (1 + B*prox)`, B = 1.6, WIN = 2, the fauna probe's smooth-min and border output.
|
||||
|
||||
Correction (2026-10-14, from the Aetheria Self's body-Voronoi probe, Aetheria 05a44541): the fauna probe's `trophic_probe.py` weights at the cell centre `(cx+.5)*LAT` (section 1 above), which is the `cellc` variant below, not `site`. An earlier label here called `site` the fauna probe's method.
|
||||
|
||||
Variants: `base` (no field); `site` (field at the jittered site position); `qpt` (field at the query point, same factor on every candidate); `cellc` (node calls the field at the integer cell centre, `cell + .5`, no jitter); `site_t0` (as `site` but field read at t=0 while wells have moved); `warp` (extra: query scaled `q*(1+B*prox(q))`).
|
||||
|
||||
### 4a. Winner identity, fraction of grid pixels whose winning cell differs (t=0 / t=8)
|
||||
|
||||
| variant | vs `site` | vs `base` |
|
||||
|---|---|---|
|
||||
| base | .147 / .156 | 0 / 0 |
|
||||
| qpt | .147 / .156 | 0 / 0 (identical argmin to base) |
|
||||
| cellc | .127 / .114 | .185 / .147 |
|
||||
| site_t0 | 0 / .226 | .147 / .147 |
|
||||
| warp | .675 / .646 | .668 / .639 |
|
||||
| site | 0 / 0 | .147 / .156 |
|
||||
|
||||
Mean absolute difference of the border output vs `site`: base .169/.186, qpt .168/.180, cellc .164/.156, site_t0 0/.234, warp .310/.280. Number of distinct cells on the grid: 53 for base, site and qpt; 54 cellc; 143/128 warp.
|
||||
|
||||
[probe] `qpt` leaves every winner unchanged because the factor is common to all candidates; it changes F1, F2 and the smooth-min scale, which moves the border line (border pixel fraction near wells .164 vs .300 for base, far .250 vs .281).
|
||||
|
||||
### 4b. Cell size and border near wells (pixels per cell, mean; "near" = within 1 well radius), t=0
|
||||
|
||||
| | near | far | border fraction near / far |
|
||||
|---|---|---|---|
|
||||
| base | 10951 | 10515 | .300 / .281 |
|
||||
| site | 9672 | 11439 | .270 / .253 |
|
||||
| qpt | 10951 | 10515 | .164 / .250 |
|
||||
| cellc | 11562 | 12274 | .274 / .262 |
|
||||
| warp | 3447 | 8566 | .291 / .302 |
|
||||
|
||||
### 4c. Pop measure, dense spatial line (6001 points over 6 cells, step 1e-3 cell), max |step| of F1 and of output, window sweep vs window 6 reference
|
||||
|
||||
- With the true winner inside the window, every variant's max F1 step is small (site .0010-.0033, qpt .0019-.0079, cellc .0009-.0041, warp .0054-.022, base .0007-.0013 per 1e-3).
|
||||
- WIN=1 (3x3): `site` F1 error vs window 6 up to .174 (diagonal line, B=1.6; max output step .95); `cellc` .155-.194; `base` .0065; `qpt` .0092-.0113; `warp` none seen. At WIN=2 and B=1.6 all variants' F1 error is 0.
|
||||
- Weight range: `site` at B=6 (weight ratio about 12:1), t=8, horizontal line: WIN=2 error .0169 (output step .371); WIN=3 error 0.
|
||||
- Note: even `base` (weights .5-1, ratio 2) shows F1 error .0065 at WIN=1.
|
||||
|
||||
### 4d. Pop measure, time at fixed query points (wells move), step refinement (worst point from a dt=.01 scan, refined)
|
||||
|
||||
| variant | dt .01 max output step | dt 1e-3 | dt 1e-4 | dt 1e-5 | max F1 step at dt 1e-5 |
|
||||
|---|---|---|---|---|---|
|
||||
| site | .1083 | .01125 | .00113 | .000113 | 2.9e-6 |
|
||||
| qpt | .00461 | .000461 | 4.6e-5 | 4.6e-6 | 4.1e-6 |
|
||||
| cellc | .1060 | .0204 | .00206 | .000207 | 5.2e-6 |
|
||||
|
||||
[probe] All three steps scale linearly with dt: no discontinuity in time for any variant when the window holds the winner. Winner-id changes over t in [0,20] at 4 query points (dt .01): base 0, qpt 0, site 17, cellc 15, site_t0 0.
|
||||
|
||||
### 4e. Caveats of the probe
|
||||
Static smooth proximity field, 4 wells, 2D, one seed of the hash; 3 boost values in the pop table only B=1.6 and 6.0; no gradient-bearing outputs measured; `cellc` field is sampled at the cell centre, so it differs from `site` by the jitter distance; no visual images rendered, only the numeric measures above. The `warp` variant is an addition outside the three asked and is reported for completeness.
|
||||
559
docs/research/lattice-site-weight/probe_results.json
Normal file
559
docs/research/lattice-site-weight/probe_results.json
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
{
|
||||
"grid": {
|
||||
"t=0.0": {
|
||||
"base": {
|
||||
"winner_differs_from_site": 0.14684,
|
||||
"winner_differs_from_base": 0.0,
|
||||
"out_absdiff_vs_site_mean": 0.1692938271424024,
|
||||
"out_absdiff_vs_site_max": 0.9999999999999667,
|
||||
"n_cells": 53
|
||||
},
|
||||
"site": {
|
||||
"winner_differs_from_site": 0.0,
|
||||
"winner_differs_from_base": 0.14684,
|
||||
"out_absdiff_vs_site_mean": 0.0,
|
||||
"out_absdiff_vs_site_max": 0.0,
|
||||
"n_cells": 53
|
||||
},
|
||||
"qpt": {
|
||||
"winner_differs_from_site": 0.14684,
|
||||
"winner_differs_from_base": 0.0,
|
||||
"out_absdiff_vs_site_mean": 0.1679024745847925,
|
||||
"out_absdiff_vs_site_max": 1.0,
|
||||
"n_cells": 53
|
||||
},
|
||||
"cellc": {
|
||||
"winner_differs_from_site": 0.126984,
|
||||
"winner_differs_from_base": 0.184648,
|
||||
"out_absdiff_vs_site_mean": 0.16449204766510442,
|
||||
"out_absdiff_vs_site_max": 0.99999757371696,
|
||||
"n_cells": 54
|
||||
},
|
||||
"warp": {
|
||||
"winner_differs_from_site": 0.6748,
|
||||
"winner_differs_from_base": 0.667952,
|
||||
"out_absdiff_vs_site_mean": 0.31037303493350155,
|
||||
"out_absdiff_vs_site_max": 1.0,
|
||||
"n_cells": 143
|
||||
},
|
||||
"site_t0": {
|
||||
"winner_differs_from_site": 0.0,
|
||||
"winner_differs_from_base": 0.14684,
|
||||
"out_absdiff_vs_site_mean": 0.0,
|
||||
"out_absdiff_vs_site_max": 0.0,
|
||||
"n_cells": 53
|
||||
},
|
||||
"site_window2_vs_window5_winner_differs": 0.0
|
||||
},
|
||||
"t=8.0": {
|
||||
"base": {
|
||||
"winner_differs_from_site": 0.155996,
|
||||
"winner_differs_from_base": 0.0,
|
||||
"out_absdiff_vs_site_mean": 0.18560227222422834,
|
||||
"out_absdiff_vs_site_max": 0.9999999025356109,
|
||||
"n_cells": 53
|
||||
},
|
||||
"site": {
|
||||
"winner_differs_from_site": 0.0,
|
||||
"winner_differs_from_base": 0.155996,
|
||||
"out_absdiff_vs_site_mean": 0.0,
|
||||
"out_absdiff_vs_site_max": 0.0,
|
||||
"n_cells": 53
|
||||
},
|
||||
"qpt": {
|
||||
"winner_differs_from_site": 0.155996,
|
||||
"winner_differs_from_base": 0.0,
|
||||
"out_absdiff_vs_site_mean": 0.17977625463968147,
|
||||
"out_absdiff_vs_site_max": 0.999999999999996,
|
||||
"n_cells": 53
|
||||
},
|
||||
"cellc": {
|
||||
"winner_differs_from_site": 0.114268,
|
||||
"winner_differs_from_base": 0.146552,
|
||||
"out_absdiff_vs_site_mean": 0.1556440909167879,
|
||||
"out_absdiff_vs_site_max": 0.9999919751523085,
|
||||
"n_cells": 54
|
||||
},
|
||||
"warp": {
|
||||
"winner_differs_from_site": 0.646396,
|
||||
"winner_differs_from_base": 0.639384,
|
||||
"out_absdiff_vs_site_mean": 0.2802002446118991,
|
||||
"out_absdiff_vs_site_max": 1.0,
|
||||
"n_cells": 128
|
||||
},
|
||||
"site_t0": {
|
||||
"winner_differs_from_site": 0.226044,
|
||||
"winner_differs_from_base": 0.14684,
|
||||
"out_absdiff_vs_site_mean": 0.23360220832359216,
|
||||
"out_absdiff_vs_site_max": 1.0,
|
||||
"n_cells": 53
|
||||
},
|
||||
"site_window2_vs_window5_winner_differs": 0.0
|
||||
}
|
||||
},
|
||||
"line_pop": {
|
||||
"horizontal z=2.6 (near well 0)": {
|
||||
"base t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.000679179751586656,
|
||||
"maxjump_out_per_step": 0.011527150729375713,
|
||||
"maxjump_f1_with_window6": 0.000679179751586656,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"base t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.000679179751586656,
|
||||
"maxjump_out_per_step": 0.011527150729375713,
|
||||
"maxjump_f1_with_window6": 0.000679179751586656,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0010050426622043585,
|
||||
"maxjump_out_per_step": 0.016002826669016756,
|
||||
"maxjump_f1_with_window6": 0.0010050426622043585,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.0010050426622043585,
|
||||
"maxjump_out_per_step": 0.016002826669016756,
|
||||
"maxjump_f1_with_window6": 0.0010050426622043585,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.001918579519211816,
|
||||
"maxjump_out_per_step": 0.03595224397909913,
|
||||
"maxjump_f1_with_window6": 0.001918579519211816,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.001918579519211816,
|
||||
"maxjump_out_per_step": 0.03595224397909913,
|
||||
"maxjump_f1_with_window6": 0.001918579519211816,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0019043178713147313,
|
||||
"maxjump_out_per_step": 0.016796471326461004,
|
||||
"maxjump_f1_with_window6": 0.0019043178713147313,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.0019043178713147313,
|
||||
"maxjump_out_per_step": 0.016796471326461004,
|
||||
"maxjump_f1_with_window6": 0.0019043178713147313,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0056772408954191,
|
||||
"maxjump_out_per_step": 0.04338667429132481,
|
||||
"maxjump_f1_with_window6": 0.0056772408954191,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.0056772408954191,
|
||||
"maxjump_out_per_step": 0.04338667429132481,
|
||||
"maxjump_f1_with_window6": 0.0056772408954191,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.000899555983544964,
|
||||
"maxjump_out_per_step": 0.028931107704223913,
|
||||
"maxjump_f1_with_window6": 0.000899555983544964,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.000899555983544964,
|
||||
"maxjump_out_per_step": 0.028998803253634242,
|
||||
"maxjump_f1_with_window6": 0.000899555983544964,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.001589550701590614,
|
||||
"maxjump_out_per_step": 0.02212547196092063,
|
||||
"maxjump_f1_with_window6": 0.001589550701590614,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.001589550701590614,
|
||||
"maxjump_out_per_step": 0.02212547196092063,
|
||||
"maxjump_f1_with_window6": 0.001589550701590614,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.005413713480415305,
|
||||
"maxjump_out_per_step": 0.11861272322439464,
|
||||
"maxjump_f1_with_window6": 0.005413713480415305,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.005413713480415305,
|
||||
"maxjump_out_per_step": 0.6979479036971218,
|
||||
"maxjump_f1_with_window6": 0.005413713480415305,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.019199908363967788,
|
||||
"maxjump_out_per_step": 0.28876383821796175,
|
||||
"maxjump_f1_with_window6": 0.019199908363967788,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.019199908363967788,
|
||||
"maxjump_out_per_step": 0.28876383821796175,
|
||||
"maxjump_f1_with_window6": 0.019199908363967788,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"base t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.000679179751586656,
|
||||
"maxjump_out_per_step": 0.011527150729375713,
|
||||
"maxjump_f1_with_window6": 0.000679179751586656,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"base t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.000679179751586656,
|
||||
"maxjump_out_per_step": 0.011527150729375713,
|
||||
"maxjump_f1_with_window6": 0.000679179751586656,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0011605444949153165,
|
||||
"maxjump_out_per_step": 0.03721603977227228,
|
||||
"maxjump_f1_with_window6": 0.0011605444949153165,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.0011605444949153165,
|
||||
"maxjump_out_per_step": 0.037218919278060314,
|
||||
"maxjump_f1_with_window6": 0.0011605444949153165,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.01726416929635244,
|
||||
"maxjump_out_per_step": 0.37104796823449204,
|
||||
"maxjump_f1_with_window6": 0.0024839789447830274,
|
||||
"f1_err_vs_win6": 0.016881040623005106
|
||||
},
|
||||
"site t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.0024839789447830274,
|
||||
"maxjump_out_per_step": 0.045046050707719165,
|
||||
"maxjump_f1_with_window6": 0.0024839789447830274,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0019988841823728176,
|
||||
"maxjump_out_per_step": 0.01873902874992317,
|
||||
"maxjump_f1_with_window6": 0.0019988841823728176,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.0019988841823728176,
|
||||
"maxjump_out_per_step": 0.01873902874992317,
|
||||
"maxjump_f1_with_window6": 0.0019988841823728176,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.005628071367035137,
|
||||
"maxjump_out_per_step": 0.04897917252830741,
|
||||
"maxjump_f1_with_window6": 0.005628071367035137,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.005628071367035137,
|
||||
"maxjump_out_per_step": 0.04897917252830741,
|
||||
"maxjump_f1_with_window6": 0.005628071367035137,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0013893624582810293,
|
||||
"maxjump_out_per_step": 0.0186819346008138,
|
||||
"maxjump_f1_with_window6": 0.0013893624582810293,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.0013893624582810293,
|
||||
"maxjump_out_per_step": 0.02079886198517822,
|
||||
"maxjump_f1_with_window6": 0.0013893624582810293,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0033405760083177194,
|
||||
"maxjump_out_per_step": 0.07493559630083158,
|
||||
"maxjump_f1_with_window6": 0.0033405760083177194,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.0033405760083177194,
|
||||
"maxjump_out_per_step": 0.07493559630083158,
|
||||
"maxjump_f1_with_window6": 0.0033405760083177194,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.005131686955684001,
|
||||
"maxjump_out_per_step": 0.10245451309831222,
|
||||
"maxjump_f1_with_window6": 0.005131686955684001,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.005131686955684001,
|
||||
"maxjump_out_per_step": 0.5048292489051344,
|
||||
"maxjump_f1_with_window6": 0.005131686955684001,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.01955931573768127,
|
||||
"maxjump_out_per_step": 0.2975581851502386,
|
||||
"maxjump_f1_with_window6": 0.01955931573768127,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.01955931573768127,
|
||||
"maxjump_out_per_step": 0.2975581851502386,
|
||||
"maxjump_f1_with_window6": 0.01955931573768127,
|
||||
"f1_err_vs_win6": 0.0
|
||||
}
|
||||
},
|
||||
"diagonal": {
|
||||
"base t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.001275910800458191,
|
||||
"maxjump_out_per_step": 0.03528728288006466,
|
||||
"maxjump_f1_with_window6": 0.001275910800458191,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"base t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.006834249882349042,
|
||||
"maxjump_out_per_step": 0.45027124784270633,
|
||||
"maxjump_f1_with_window6": 0.001275910800458191,
|
||||
"f1_err_vs_win6": 0.006469535309243879
|
||||
},
|
||||
"site t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0018160559768694018,
|
||||
"maxjump_out_per_step": 0.036994428279204405,
|
||||
"maxjump_f1_with_window6": 0.0018160559768694018,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.17389687729083692,
|
||||
"maxjump_out_per_step": 0.9512571305077048,
|
||||
"maxjump_f1_with_window6": 0.0018160559768694018,
|
||||
"f1_err_vs_win6": 0.17352236500589702
|
||||
},
|
||||
"site t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0032951968389483,
|
||||
"maxjump_out_per_step": 0.08101849084444412,
|
||||
"maxjump_f1_with_window6": 0.0032951968389483,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.0032951968389483,
|
||||
"maxjump_out_per_step": 0.08049836455119364,
|
||||
"maxjump_f1_with_window6": 0.0032951968389483,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0026472302788482782,
|
||||
"maxjump_out_per_step": 0.03868824720262809,
|
||||
"maxjump_f1_with_window6": 0.0026472302788482782,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.010707146916472454,
|
||||
"maxjump_out_per_step": 0.21289370234612265,
|
||||
"maxjump_f1_with_window6": 0.0026472302788482782,
|
||||
"f1_err_vs_win6": 0.009199392603544854
|
||||
},
|
||||
"qpt t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.007883634786070015,
|
||||
"maxjump_out_per_step": 0.07213687421025916,
|
||||
"maxjump_f1_with_window6": 0.007883634786070015,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.007883634786070015,
|
||||
"maxjump_out_per_step": 0.07213687421025916,
|
||||
"maxjump_f1_with_window6": 0.007883634786070015,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0021042254469000876,
|
||||
"maxjump_out_per_step": 0.03605934023336077,
|
||||
"maxjump_f1_with_window6": 0.0021042254469000876,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.15454958335751312,
|
||||
"maxjump_out_per_step": 0.47361732317771077,
|
||||
"maxjump_f1_with_window6": 0.0021042254469000876,
|
||||
"f1_err_vs_win6": 0.15418392181872642
|
||||
},
|
||||
"cellc t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.004089971897996092,
|
||||
"maxjump_out_per_step": 0.04544931913493744,
|
||||
"maxjump_f1_with_window6": 0.004089971897996092,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.004089971897996092,
|
||||
"maxjump_out_per_step": 0.04544931913493744,
|
||||
"maxjump_f1_with_window6": 0.004089971897996092,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.007786414559647126,
|
||||
"maxjump_out_per_step": 0.10615772398185219,
|
||||
"maxjump_f1_with_window6": 0.007786414559647126,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.007786414559647126,
|
||||
"maxjump_out_per_step": 0.10615772398185219,
|
||||
"maxjump_f1_with_window6": 0.007786414559647126,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0220788526153487,
|
||||
"maxjump_out_per_step": 0.2726388272791984,
|
||||
"maxjump_f1_with_window6": 0.0220788526153487,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=0.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.0220788526153487,
|
||||
"maxjump_out_per_step": 0.2726388272791984,
|
||||
"maxjump_f1_with_window6": 0.0220788526153487,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"base t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.001275910800458191,
|
||||
"maxjump_out_per_step": 0.03528728288006466,
|
||||
"maxjump_f1_with_window6": 0.001275910800458191,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"base t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.006834249882349042,
|
||||
"maxjump_out_per_step": 0.45027124784270633,
|
||||
"maxjump_f1_with_window6": 0.001275910800458191,
|
||||
"f1_err_vs_win6": 0.006469535309243879
|
||||
},
|
||||
"site t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.00155291340314212,
|
||||
"maxjump_out_per_step": 0.04226739191047357,
|
||||
"maxjump_f1_with_window6": 0.00155291340314212,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.11344119297302535,
|
||||
"maxjump_out_per_step": 0.3917028634066005,
|
||||
"maxjump_f1_with_window6": 0.00155291340314212,
|
||||
"f1_err_vs_win6": 0.11285505036112697
|
||||
},
|
||||
"site t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.002453426564246408,
|
||||
"maxjump_out_per_step": 0.03688685308461992,
|
||||
"maxjump_f1_with_window6": 0.002453426564246408,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"site t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.002453426564246408,
|
||||
"maxjump_out_per_step": 0.03688685308461992,
|
||||
"maxjump_f1_with_window6": 0.002453426564246408,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0018171966866530331,
|
||||
"maxjump_out_per_step": 0.03560130205583023,
|
||||
"maxjump_f1_with_window6": 0.0018171966866530331,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.011573594040749535,
|
||||
"maxjump_out_per_step": 0.44945714067888365,
|
||||
"maxjump_f1_with_window6": 0.0018171966866530331,
|
||||
"f1_err_vs_win6": 0.011263134181283219
|
||||
},
|
||||
"qpt t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.004178672365624969,
|
||||
"maxjump_out_per_step": 0.06700822884555246,
|
||||
"maxjump_f1_with_window6": 0.004178672365624969,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"qpt t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.004178672365624969,
|
||||
"maxjump_out_per_step": 0.06700822884555246,
|
||||
"maxjump_f1_with_window6": 0.004178672365624969,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0013657606892926166,
|
||||
"maxjump_out_per_step": 0.03848639655536479,
|
||||
"maxjump_f1_with_window6": 0.0013657606892926166,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.19448286979973972,
|
||||
"maxjump_out_per_step": 0.9778972151563999,
|
||||
"maxjump_f1_with_window6": 0.0013657606892926166,
|
||||
"f1_err_vs_win6": 0.1940193666886757
|
||||
},
|
||||
"cellc t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.0016119831862634326,
|
||||
"maxjump_out_per_step": 0.05348368104496215,
|
||||
"maxjump_f1_with_window6": 0.0016119831862634326,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"cellc t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.0016119831862634326,
|
||||
"maxjump_out_per_step": 0.05348368104496215,
|
||||
"maxjump_f1_with_window6": 0.0016119831862634326,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=1.6 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.00389655575000758,
|
||||
"maxjump_out_per_step": 0.06278112824237458,
|
||||
"maxjump_f1_with_window6": 0.00389655575000758,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=1.6 WIN=1": {
|
||||
"maxjump_f1_per_step": 0.00389655575000758,
|
||||
"maxjump_out_per_step": 0.4429137542853424,
|
||||
"maxjump_f1_with_window6": 0.00389655575000758,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=6.0 WIN=2": {
|
||||
"maxjump_f1_per_step": 0.012386023943556368,
|
||||
"maxjump_out_per_step": 0.1972931166165931,
|
||||
"maxjump_f1_with_window6": 0.012386023943556368,
|
||||
"f1_err_vs_win6": 0.0
|
||||
},
|
||||
"warp t=8.0 B=6.0 WIN=3": {
|
||||
"maxjump_f1_per_step": 0.012386023943556368,
|
||||
"maxjump_out_per_step": 0.1972931166165931,
|
||||
"maxjump_f1_with_window6": 0.012386023943556368,
|
||||
"f1_err_vs_win6": 0.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"time_pop": {
|
||||
"base": {
|
||||
"maxjump_f1_per_dt": 0.0,
|
||||
"maxjump_out_per_dt": 0.0,
|
||||
"winner_changes_total": 0
|
||||
},
|
||||
"site": {
|
||||
"maxjump_f1_per_dt": 0.006243969970252006,
|
||||
"maxjump_out_per_dt": 0.10827554277384255,
|
||||
"winner_changes_total": 17
|
||||
},
|
||||
"qpt": {
|
||||
"maxjump_f1_per_dt": 0.004880867091880958,
|
||||
"maxjump_out_per_dt": 0.004611044852681684,
|
||||
"winner_changes_total": 0
|
||||
},
|
||||
"cellc": {
|
||||
"maxjump_f1_per_dt": 0.006179970448791816,
|
||||
"maxjump_out_per_dt": 0.10604425529368433,
|
||||
"winner_changes_total": 15
|
||||
},
|
||||
"site_t0": {
|
||||
"maxjump_f1_per_dt": 0.0,
|
||||
"maxjump_out_per_dt": 0.0,
|
||||
"winner_changes_total": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
140
docs/research/lattice-site-weight/probe_results2.json
Normal file
140
docs/research/lattice-site-weight/probe_results2.json
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
{
|
||||
"timejump site dt=0.01": {
|
||||
"q": [
|
||||
2.3,
|
||||
2.8
|
||||
],
|
||||
"t_worst": 9.91,
|
||||
"max_out_step": 0.10827554277384255,
|
||||
"max_f1_step": 0.0028572323710521763
|
||||
},
|
||||
"timejump site dt=0.001": {
|
||||
"q": [
|
||||
2.3,
|
||||
2.8
|
||||
],
|
||||
"t_worst": 9.91,
|
||||
"max_out_step": 0.011248851985623975,
|
||||
"max_f1_step": 0.00028847749905558384
|
||||
},
|
||||
"timejump site dt=0.0001": {
|
||||
"q": [
|
||||
2.3,
|
||||
2.8
|
||||
],
|
||||
"t_worst": 9.91,
|
||||
"max_out_step": 0.00113067110268672,
|
||||
"max_f1_step": 2.8874698390524145e-05
|
||||
},
|
||||
"timejump site dt=1e-05": {
|
||||
"q": [
|
||||
2.3,
|
||||
2.8
|
||||
],
|
||||
"t_worst": 9.91,
|
||||
"max_out_step": 0.00011316362556801352,
|
||||
"max_f1_step": 2.8877387294734547e-06
|
||||
},
|
||||
"timejump qpt dt=0.01": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 16.21,
|
||||
"max_out_step": 0.00461104485268235,
|
||||
"max_f1_step": 0.004042127425204045
|
||||
},
|
||||
"timejump qpt dt=0.001": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 16.21,
|
||||
"max_out_step": 0.0004611240505579539,
|
||||
"max_f1_step": 0.000406360115853599
|
||||
},
|
||||
"timejump qpt dt=0.0001": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 16.21,
|
||||
"max_out_step": 4.611242486959877e-05,
|
||||
"max_f1_step": 4.065726355373389e-05
|
||||
},
|
||||
"timejump qpt dt=1e-05": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 16.21,
|
||||
"max_out_step": 4.611242504171109e-06,
|
||||
"max_f1_step": 4.065938649877587e-06
|
||||
},
|
||||
"timejump cellc dt=0.01": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 0.39,
|
||||
"max_out_step": 0.10604425529368433,
|
||||
"max_f1_step": 0.005110972264514513
|
||||
},
|
||||
"timejump cellc dt=0.001": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 0.39,
|
||||
"max_out_step": 0.020376801353283347,
|
||||
"max_f1_step": 0.0005172341605852449
|
||||
},
|
||||
"timejump cellc dt=0.0001": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 0.39,
|
||||
"max_out_step": 0.002064834070920063,
|
||||
"max_f1_step": 5.178482072565327e-05
|
||||
},
|
||||
"timejump cellc dt=1e-05": {
|
||||
"q": [
|
||||
5.9,
|
||||
1.9
|
||||
],
|
||||
"t_worst": 0.39,
|
||||
"max_out_step": 0.00020688477103125713,
|
||||
"max_f1_step": 5.179096147611695e-06
|
||||
},
|
||||
"shape base": {
|
||||
"mean_cell_pixels_near_wells": 10951.06215310558,
|
||||
"mean_cell_pixels_far": 10515.08654105133,
|
||||
"border_frac_near": 0.3004222385907398,
|
||||
"border_frac_far": 0.28125746844758714
|
||||
},
|
||||
"shape site": {
|
||||
"mean_cell_pixels_near_wells": 9671.733834071645,
|
||||
"mean_cell_pixels_far": 11438.643308839986,
|
||||
"border_frac_near": 0.2704401863244494,
|
||||
"border_frac_far": 0.25280623954417336
|
||||
},
|
||||
"shape qpt": {
|
||||
"mean_cell_pixels_near_wells": 10951.06215310558,
|
||||
"mean_cell_pixels_far": 10515.08654105133,
|
||||
"border_frac_near": 0.1644136900747995,
|
||||
"border_frac_far": 0.2500635410778843
|
||||
},
|
||||
"shape cellc": {
|
||||
"mean_cell_pixels_near_wells": 11562.292900789493,
|
||||
"mean_cell_pixels_far": 12273.640744435888,
|
||||
"border_frac_near": 0.27424759572989177,
|
||||
"border_frac_far": 0.2618651108270203
|
||||
},
|
||||
"shape warp": {
|
||||
"mean_cell_pixels_near_wells": 3446.9220259152826,
|
||||
"mean_cell_pixels_far": 8566.10048594699,
|
||||
"border_frac_near": 0.29084665580811486,
|
||||
"border_frac_far": 0.3016702768114897
|
||||
}
|
||||
}
|
||||
123
docs/research/lattice-site-weight/siteweight_probe.py
Normal file
123
docs/research/lattice-site-weight/siteweight_probe.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Scratch probe: where does a weighted lattice Voronoi cell get its weight?
|
||||
2D, lattice cell = 1 unit, wells with a proximity field, numpy only.
|
||||
Variants of the per-site weight wt_i (multiplies the distance to site i, as the fauna probe's lattice_web):
|
||||
base : wt = hashed .5..1, no field (reference, the unweighted-by-field node)
|
||||
site : wt = base*(1+B*prox(site position, t)) (fauna probe, 4354ef2c)
|
||||
qpt : wt = base*(1+B*prox(query position, t)) (common factor for every candidate site)
|
||||
cellc : wt = base*(1+B*prox(lattice cell centre, t)) (node calls the field at the integer cell id)
|
||||
site_t0: as site, but field read at t=0 (stale time) while wells have moved
|
||||
warp : base weights, query warped q' = q*(1+B*prox(q)) (domain scaling, a different construct)
|
||||
"""
|
||||
import numpy as np, sys, json
|
||||
rng_seed = 7
|
||||
def hash01(ix, iz, k):
|
||||
# integer hash (lowbias32-style), stand-in for pcg3d; deterministic, vectorised
|
||||
x = (ix.astype(np.uint64)*np.uint64(73856093) ^ iz.astype(np.uint64)*np.uint64(19349663) ^ np.uint64(k*83492791+rng_seed)) & np.uint64(0xFFFFFFFF)
|
||||
x ^= x >> np.uint64(16); x = (x*np.uint64(0x7feb352d)) & np.uint64(0xFFFFFFFF)
|
||||
x ^= x >> np.uint64(15); x = (x*np.uint64(0x846ca68b)) & np.uint64(0xFFFFFFFF)
|
||||
x ^= x >> np.uint64(16)
|
||||
return (x >> np.uint64(8)).astype(np.float64)/16777216.0
|
||||
|
||||
W0 = np.array([[2.0,2.5],[5.5,4.0],[3.5,6.0],[6.5,1.5]]); WS = np.array([0.8,1.2,0.6,1.0])
|
||||
OM = np.array([0.30,-0.22,0.17,-0.41]) # well orbit rate, rad per unit t (wells orbit a centre)
|
||||
CEN = np.array([4.0,4.0])
|
||||
def wells(t):
|
||||
r = W0-CEN; c, s = np.cos(OM*t), np.sin(OM*t)
|
||||
return CEN + np.stack([r[:,0]*c-r[:,1]*s, r[:,0]*s+r[:,1]*c], 1)
|
||||
def prox(x, z, t):
|
||||
w = wells(t); out = 0
|
||||
for k in range(len(w)):
|
||||
out = np.maximum(out, np.exp(-(np.hypot(x-w[k,0], z-w[k,1])/WS[k])**2))
|
||||
return out
|
||||
|
||||
KW=.35
|
||||
def node(qx, qz, variant, t=0.0, B=1.6, WIN=2, tfield=None):
|
||||
"""returns f1, f2, winning cell id (ix*10007+iz), soft border value"""
|
||||
qx0, qz0 = qx, qz
|
||||
if variant == 'warp':
|
||||
s = 1+B*prox(qx, qz, t); qx, qz = qx*s, qz*s
|
||||
nx, nz = np.floor(qx), np.floor(qz)
|
||||
m = np.full(qx.shape, 8.0); f1 = m.copy(); f2 = m.copy(); wid = np.zeros(qx.shape)
|
||||
pq = prox(qx0, qz0, t)
|
||||
tf = t if tfield is None else tfield
|
||||
for j in range(-WIN, WIN+1):
|
||||
for i in range(-WIN, WIN+1):
|
||||
cx, cz = nx+i, nz+j
|
||||
ox, oz = hash01(cx, cz, 11), hash01(cx, cz, 12)
|
||||
base = hash01(cx, cz, 13)*.5+.5
|
||||
sx, sz = cx+ox, cz+oz
|
||||
if variant in ('base','warp'): wt = base
|
||||
elif variant == 'site': wt = base*(1+B*prox(sx, sz, tf))
|
||||
elif variant == 'site_t0': wt = base*(1+B*prox(sx, sz, 0.0))
|
||||
elif variant == 'cellc': wt = base*(1+B*prox(cx+.5, cz+.5, tf))
|
||||
elif variant == 'qpt': wt = base*(1+B*pq)
|
||||
d = np.hypot(sx-qx, sz-qz)*wt
|
||||
upd1 = d < f1; upd2 = (~upd1) & (d < f2)
|
||||
wid = np.where(upd1, cx*10007+cz, wid)
|
||||
f2 = np.where(upd1, f1, np.where(upd2, d, f2)); f1 = np.where(upd1, d, f1)
|
||||
h = (lambda u: u*u*(3-2*u))(np.clip(.5+.5*(m-d)/KW, 0, 1))
|
||||
m = m+(d-m)*h-h*(1-h)*KW/(1+3*KW)
|
||||
gap0 = .25*KW/(1+3*KW)
|
||||
soft = np.clip((f1-m)/gap0, 0, 1)
|
||||
out = np.maximum(soft**2, np.exp(-((f2-f1)/.10)**2))
|
||||
return f1, f2, wid, out
|
||||
|
||||
def grid(n=500, lo=1.0, hi=7.0):
|
||||
xs = np.linspace(lo, hi, n); X, Z = np.meshgrid(xs, xs); return X, Z
|
||||
|
||||
def main():
|
||||
R = {}
|
||||
X, Z = grid()
|
||||
for t in (0.0, 8.0):
|
||||
ref = {v: node(X, Z, v, t) for v in ('base','site','qpt','cellc','warp')}
|
||||
ref['site_t0'] = node(X, Z, 'site_t0', t)
|
||||
# large-window truth for 'site' (exact argmin unless window too small)
|
||||
truth = node(X, Z, 'site', t, WIN=5)[2]
|
||||
key = f"t={t}"; R[key] = {}
|
||||
for v, (f1, f2, wid, out) in ref.items():
|
||||
R[key][v] = dict(
|
||||
winner_differs_from_site=float((wid != ref['site'][2]).mean()),
|
||||
winner_differs_from_base=float((wid != ref['base'][2]).mean()),
|
||||
out_absdiff_vs_site_mean=float(np.abs(out-ref['site'][3]).mean()),
|
||||
out_absdiff_vs_site_max=float(np.abs(out-ref['site'][3]).max()),
|
||||
n_cells=int(len(np.unique(wid))))
|
||||
R[key]['site_window2_vs_window5_winner_differs'] = float((ref['site'][2] != truth).mean())
|
||||
# cell count density near wells for site vs base
|
||||
# pop measure: dense line, step 1e-3 units; also jumps in time at fixed q
|
||||
ts = np.linspace(1.0, 7.0, 6001) # 1e-3
|
||||
lines = {'horizontal z=2.6 (near well 0)': (ts, np.full_like(ts, 2.6)),
|
||||
'diagonal': (ts, 1.0+ts*0.9-0.5)}
|
||||
P = {}
|
||||
for ln, (lx, lz) in lines.items():
|
||||
P[ln] = {}
|
||||
for t in (0.0, 8.0):
|
||||
for v in ('base','site','qpt','cellc','warp'):
|
||||
for B, WIN in ((1.6,2),(1.6,1),(6.0,2),(6.0,3)):
|
||||
if v in ('base',) and B != 1.6: continue
|
||||
f1, f2, wid, out = node(lx, lz, v, t, B, WIN)
|
||||
ref = node(lx, lz, v, t, B, 6)
|
||||
P[ln][f"{v} t={t} B={B} WIN={WIN}"] = dict(
|
||||
maxjump_f1_per_step=float(np.abs(np.diff(f1)).max()),
|
||||
maxjump_out_per_step=float(np.abs(np.diff(out)).max()),
|
||||
maxjump_f1_with_window6=float(np.abs(np.diff(ref[0])).max()),
|
||||
f1_err_vs_win6=float(np.abs(f1-ref[0]).max()))
|
||||
# time pop: fixed query points, t stepped at 1e-2; wells move
|
||||
tt = np.linspace(0, 20, 2001)
|
||||
qs = [(2.3,2.8),(4.7,3.9),(3.5,5.5),(5.9,1.9)]
|
||||
T = {}
|
||||
for v in ('base','site','qpt','cellc','site_t0'):
|
||||
mj = 0; mo = 0; changes = 0
|
||||
for (qx, qz) in qs:
|
||||
f1s=[];outs=[];wids=[]
|
||||
for t in tt:
|
||||
a,b,w,o = node(np.array([qx]), np.array([qz]), v, t)
|
||||
f1s.append(a[0]); outs.append(o[0]); wids.append(w[0])
|
||||
mj=max(mj,np.abs(np.diff(f1s)).max()); mo=max(mo,np.abs(np.diff(outs)).max())
|
||||
changes += int((np.diff(wids)!=0).sum())
|
||||
T[v] = dict(maxjump_f1_per_dt=float(mj), maxjump_out_per_dt=float(mo), winner_changes_total=changes)
|
||||
json.dump(dict(grid=R, line_pop=P, time_pop=T), open('probe_results.json','w'), indent=1)
|
||||
print(json.dumps(R, indent=1)); print(json.dumps(T, indent=1))
|
||||
for ln in P:
|
||||
print(ln)
|
||||
for k,v in P[ln].items(): print(' ', k, {a: round(b,5) for a,b in v.items()})
|
||||
main()
|
||||
37
docs/research/lattice-site-weight/siteweight_probe2.py
Normal file
37
docs/research/lattice-site-weight/siteweight_probe2.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Follow-up: (1) continuity refinement: does the max time-jump of 'site' scale with dt? (2) cell size near vs far from wells,
|
||||
border-pixel fraction per variant."""
|
||||
import numpy as np, json, importlib.util, sys, io, contextlib
|
||||
spec = importlib.util.spec_from_file_location('p', 'siteweight_probe.py')
|
||||
src = open('siteweight_probe.py').read().replace('\nmain()\n', '\n') # import without running main
|
||||
ns = {}; exec(compile(src, 'p', 'exec'), ns)
|
||||
node, prox, grid, wells = ns['node'], ns['prox'], ns['grid'], ns['wells']
|
||||
res = {}
|
||||
# (1) worst time step at dt=.01 for each variant, then refine dt inside +-.02 of it (continuity: step should shrink ~ with dt)
|
||||
qs = [(2.3,2.8),(4.7,3.9),(3.5,5.5),(5.9,1.9)]
|
||||
def series(v, qx, qz, tt):
|
||||
f1 = np.empty(len(tt)); out = np.empty(len(tt))
|
||||
for k, t in enumerate(tt):
|
||||
a,b,w,o = node(np.array([qx]), np.array([qz]), v, t); f1[k]=a[0]; out[k]=o[0]
|
||||
return f1, out
|
||||
for v in ('site','qpt','cellc'):
|
||||
tt = np.arange(0, 20.0001, 0.01); best = (0, None, None)
|
||||
for qx, qz in qs:
|
||||
f1, out = series(v, qx, qz, tt); i = int(np.argmax(np.abs(np.diff(out))))
|
||||
if abs(out[i+1]-out[i]) > best[0]: best = (abs(out[i+1]-out[i]), (qx,qz), tt[i])
|
||||
_, q, t0 = best
|
||||
for dt in (0.01, 0.001, 0.0001, 0.00001):
|
||||
t2 = np.arange(t0-0.02, t0+0.03, dt); f1, out = series(v, q[0], q[1], t2)
|
||||
res[f"timejump {v} dt={dt}"] = dict(q=q, t_worst=float(t0), max_out_step=float(np.abs(np.diff(out)).max()), max_f1_step=float(np.abs(np.diff(f1)).max()))
|
||||
print(v, dt, res[f"timejump {v} dt={dt}"], flush=True)
|
||||
# (2) cell area near vs far wells, border fraction
|
||||
X, Z = grid(600)
|
||||
w = wells(0.0)
|
||||
near = np.min([np.hypot(X-w[k,0], Z-w[k,1])/ns['WS'][k] for k in range(4)], axis=0) < 1.0
|
||||
for v in ('base','site','qpt','cellc','warp'):
|
||||
f1,f2,wid,out = node(X, Z, v, 0.0)
|
||||
ids, cnt = np.unique(wid, return_counts=True); area = dict(zip(ids, cnt))
|
||||
pa = np.array([area[i] for i in wid.ravel()]).reshape(wid.shape)
|
||||
res[f"shape {v}"] = dict(mean_cell_pixels_near_wells=float(pa[near].mean()), mean_cell_pixels_far=float(pa[~near].mean()),
|
||||
border_frac_near=float((out[near]>.5).mean()), border_frac_far=float((out[~near]>.5).mean()))
|
||||
print(v, res[f"shape {v}"])
|
||||
json.dump(res, open('probe_results2.json','w'), indent=1)
|
||||
80
docs/research/span-chain-and-site-field.md
Normal file
80
docs/research/span-chain-and-site-field.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Facts for `cultmath-tapes:question:span-chain-shape` and `cultmath-tapes:question:site-ground-field` (2026-10-14)
|
||||
|
||||
Eyes, read only. Tags: [src] primary source text read (paper or code); [doc] vendor or library documentation, or a committed CultLib doc; [web] web summary, second-hand where noted (WebFetch/WebSearch summarisers, not the paper text); [abs] derived by reasoning, not a measurement. Not repeated here: `docs/research/integration-native-noise-domain.md`, `culling-efficiency-oracle-prior-art.md` (its section 3 already carries Keeter MPR, Heidrich 1998, Gamito and Maddock 2007, Knoll Tables 1-2 fps ratios, Sharp and Jacobson, Mitchell timings), `tape-register-model.md` (Fidget and MPR tape, allocator, interpreter overhead). Raw downloads and pdftotext output: `w/` beside this file (not meant for commit).
|
||||
|
||||
## Part A. span-chain-shape
|
||||
|
||||
### A1. Taylor models and Taylor forms (Berz, Makino; Neumaier's review)
|
||||
|
||||
- [src] Neumaier, *Taylor forms: use and limits* (2002, `https://arnold-neumaier.at/ms/taylor.pdf`, read via pdftotext). Taylor forms "compute recursively a high order polynomial approximation to a multivariate Taylor expansion, with a remainder term that rigorously bounds the approximation error" (section 1). Storage and work are proportional to C(n+d, d) coefficients for degree d in n variables, times the number of arithmetic operations. Centered forms are "the degree 1 case of Taylor forms".
|
||||
- [src] Same, section 1: "Rigorous multivariate Taylor arithmetic with remainder, for the four elementary operations and composition exists at least since 1984 (Eckmann et al.)". Composition gives "arbitrary analytic standard functions for which polynomial enclosures with error bounds are available" (section 3). Berz's group popularised the variant named Taylor models from 1996; COSY INFINITY is the implementation. Two coefficient conventions: Eckmann et al. use narrow interval coefficients with a norm-bound remainder; COSY uses floating-point coefficients plus an interval remainder.
|
||||
- [src] Same, section 3: first-order Taylor models "are a simplified version" of slope-based centered forms in which "the width of the linear coefficients is moved to the remainder term"; "comparisons between first order Taylor models and the mean value form ... are not available". Slopes are more accurate than simple Taylor-form implementations because of the subdistributive law.
|
||||
- [src] Same, section 4: interval evaluation has linear approximation order; centered forms have quadratic approximation order (overestimation proportional to box width squared) "under mild conditions excluding near-singular cases". Near stationary points a Taylor form of narrow intervals "suffers from the same problem as other centered forms".
|
||||
- [src] Same, section 3: "Over sufficiently wide boxes, the Taylor form shares the fate of any centered form, that it usually gives a large overestimation and may even be poorer than naive interval evaluation. It is not designed for such applications".
|
||||
- [src] Same, section 7: three overestimation sources: wrapping (depth of the computational graph, "long sequences of nested operations depending on a limited number of variables only"), cancellation, dependence. "The amount of wrapping is related to the level of nestedness, i.e., the depth of the reduced computational tree obtained by combining linear combinations into single nodes", but the size and sign of the coefficients decide how much occurs; a complete analysis is "possible only for well-structured simple cases".
|
||||
- [src] Same, section 11, worked example f(x)=1/(1-x) - 1/(2-x) ... at x=[-r,r]: naive interval radius 5r/4+O(r^3); centered form 3r/4+9r^2/8; arbitrary-order Taylor expansion gives radius 3r/4+7r^2/16, "marginal additional improvement factor of 1-11r/12 only". Neumaier's reading: when the quadratic approximation property is already effective, higher order gives little; check the computable overestimation bound (his Theorem 8.1) against the width and raise the order only if it is too large.
|
||||
- [web] (second-hand, via search summary of Makino's 2004 slides, `https://rec.ce.gatech.edu/rec2004/presentations/Makino.pdf`, not read in full) If the remainder bounds of f and g are O(h^(n+1)) then those of f+g and f*g are too; intrinsic functions are composed from binary operations plus an extra interval whose width scales at least with the (n+1)-st power of a bound B of a function that scales at least linearly with h; hence order n+1 scaling survives composition.
|
||||
- [web] (second-hand) Caltech paper `https://resolver.caltech.edu/CaltechAUTHORS:20130919-113604950` ("Convergence analysis of Taylor models and McCormick-Taylor models"): proves convergence order of q-th order Taylor models is at least q+1 for addition, multiplication and composition under mild assumptions; the summary says the bounds are verified sharp on simple numerical examples. That paper is on McCormick-Taylor variants, so constants may differ from COSY's.
|
||||
- [web] (second-hand) The remainder interval in validated ODE integration "can never decrease", so the dependency impact grows with integration steps; shrink wrapping and preconditioning are added for that (Neumaier section 13-14 discuss wrapping in discrete dynamical systems and ODE enclosures, [src], not applicable to a feed-forward chain except as the definition of wrapping above).
|
||||
- [src] Streeter and Dillon, *Automatically Bounding the Taylor Remainder Series* (arXiv 2212.11429v3, abstract read): takes a function in symbolic form built from known atomic functions, a reference point, a trust region and an order k; derives sharp polynomial bounds on the remainder of exp, log, etc.; "combines those bounds through the function's composition" with "an interval arithmetic variant of Taylor-mode automatic differentiation". Open-source JAX implementation. The abstract page gives no comparison against interval arithmetic or Taylor models and no benchmark numbers.
|
||||
- [abs] The Span Lab's per-span model (linearise the chain, bound the error with 0.5 K h^2, K from a chain of speed, acceleration, Jacobian and second-derivative bounds; map Pass 8) has the structure of a first-order Taylor model with a Lagrange-form remainder from a second-derivative bound. Its remainder order is h^2, equal to the "quadratic approximation order" Neumaier states for centered forms. [doc] for the lab's construction: `F:\Projects\eureka-handoff-aux-2026-10-14\eyes-nebula-labs\facts.md` section 1a and `docs/cultmath-interval-ground-cut.md` Pass 8.
|
||||
|
||||
### A2. Mitchell 1990, Knoll et al. 2009, Gamito and Maddock: how a chain is represented
|
||||
|
||||
- [src] Mitchell, *Robust Ray Intersection with Interval Arithmetic* (Graphics Interface 1990, pp. 68-74, `https://graphicsinterface.org/wp-content/uploads/gi1990-8.pdf`, pdftotext read). The surface is a ray-substituted one-variable function f(t)=F(S+tD). The test is a three-valued root inclusion ("yes", "no", "maybe"; bisect on "maybe"): Step 1 evaluate the interval extension of f, no zero means no root; Step 2 evaluate the interval extension of the derivative, no zero means monotonic, so refine by a standard method; Step 3 both contain zero, bisect; Step 4 stop at machine-accuracy width.
|
||||
- [src] Same, section 3: "Given an expression F(x,y,z) in symbolic postfix form, a simple interpreter can compute an interval evaluation. By application of the chain rule for differentiation, the value of grad F can be computed concurrently." Derivative of f is the interval extension of D . grad F. Monotonic functions have trivial interval extensions (e^[a,b] = [e^a, e^b]); abs is [0, max(|a|,|b|)]; sine and cosine use known extrema locations to give "exact upper and lower bounds". Division must return [-inf, inf] when the divisor interval contains zero (superquadric |x|^0.75 has a derivative singularity at 0). Rounding: single precision was "sufficient" for the images; outward rounding is "safer but more computationally expensive". So the chain is one compiled postfix expression, value and gradient bounds computed together, with bounds per elementary node.
|
||||
- [src] Knoll, Hijazi, Kensler, Schott, Hansen, Hagen, *Fast Ray Tracing of Arbitrary Implicit Surfaces with Interval and Affine Arithmetic* (CGF 28(1), 2009; `https://www.sci.utah.edu/~knolla/cgrtia.pdf`, pdftotext read): the user supplies an implicit function in text; "we use a simple recursive-descent parser to generate these routines in the output Cg shader": one routine for f and one for its inclusion function (interval or affine extension), generated by shader metaprogramming and compiled as one shader. "Because the shader compiler identifies common subexpressions, [inline code] is seldom necessary for improving performance" (section 5.1). Whole expression as one generated function, not stage functions.
|
||||
- [src] Same: GPU algorithm is stackless interval bisection with a fixed per-ray maximum depth derived from a user precision epsilon; benchmarked at 1024x1024 on an NVIDIA 8800GTX (GPU) and a 4-core Xeon 2.33 GHz (SSE). RAA "generally 1.5-2x faster than interval arithmetic" for moderate cross-multiplication, "3 to 4 times faster" for high-overestimation forms (Barth decic, Horner forms); IA stays "far more efficient for superquadrics" because of an efficient integer-power rule; "IA is currently required for extensions of division, transcendentals, and fractional powers" (section 5.4 as cited). Quality: "RAA generally converges far more quickly than IA, given lesser bound overestimation at low epsilon"; RAA "has worse numerical conditioning" than IA.
|
||||
- [src] Same, section 6.1.3 text: GPU implementations of the Mitchell algorithm (interval evaluation then Newton-style refinement) "perform far worse than naive bisection, particularly at higher epsilon", attributed to "the high cost of evaluating the gradient interval, and both worse instruction-level coherence on the GPU and spatial coherence in the SSE CPU algorithm"; interval Newton and quasi-Newton variants "empirically suggested far worse results" on the SIMD CPU implementation. The paper's Table 1 figures (e.g. Mitchell surface IA 63/34/19/13 fps vs RAA 80/64/59/56 fps across four epsilon values; Barth decic IA 29/13/7.1/4.9 vs RAA 25/19/17/16) are in the figure caption region of the extracted text; the table layout is mangled and I did not re-derive the row mapping.
|
||||
- [doc] Already committed: Gamito and Maddock 2007 (noise ray casting, reduced affine arithmetic, evaluations per ray) in `culling-efficiency-oracle-prior-art.md` section 3. The Gamito paper `https://eprints.whiterose.ac.uk/id/eprint/3562/1/gamito4.pdf` (downloaded, 10 pages) describes procedural noise as "a sum of kernel functions ... that depend on the displacement vectors between x and the node points", each evaluated in affine arithmetic; I did not find a warp chain in it.
|
||||
- [src] Zanni, *Synchronized-tracing of implicit surfaces* (arXiv 2304.09673, text read in part): states two representations of a field expression on GPU: "function calls directly hard coded inside a shader ... good performance can be achieved for reasonable size expressions at the expense of shader recompilation each time the expression is modified", versus the expression as data with an interpreter (post-order tree, Grasberger 2016; tape for an arithmetic DAG, Keeter 2020). It uses node-level interpreters rather than a math-expression interpreter: they "decrease the number of switch executions and require less storage of data" and "can support fully procedural nodes", at the price of a more complex shader that needs recompiling if the primitive/operator family changes. Its blobtree has "space deformation moved to the leaves (our implementation only supports rotation and translation of primitives)". It restates that Hart's sphere tracing uses a Lipschitz bound, and that Segment tracing (Galin et al. 2020) uses "directional Lipschitz bound on ray subintervals", with a variant for deformed surfaces (Seyb et al. 2019).
|
||||
- [web] (abstract only; the paper PDF at diglib7.eg.org returned an HTML login page to curl) Galin, Guerin, Paris, Peytavie, *Segment Tracing Using Local Lipschitz Bounds* (CGF 39(2), 2020, pp. 545-554, `https://diglib.eg.org/handle/10.1111/cgf13951`): computes the Lipschitz bound locally over a ray segment; "describe the computation of the Lipschitz bound for different operators and primitives"; reports significantly fewer field-function queries than previous methods without extra acceleration structures; no figures in the abstract. I did not read its per-operator rules.
|
||||
|
||||
### A3. Keeter MPR and Fidget: stage functions vs one compiled tree
|
||||
|
||||
- [doc] Already committed in `tape-register-model.md` section 1: MPR and Fidget evaluate the whole shape as one flat clause tape (clause = opcode plus output slot plus up to two input slots), interval arithmetic over the tape picks min/max branches and records choices, then the tape is shortened per tile (6056 clauses to a mean of 356 in 64^2 tiles and 28 in 8^2 subtiles; `culling-efficiency-oracle-prior-art.md` section 3). Interpreter overhead versus a compiled kernel about 19x (58 ms/MP vs 3 ms/MP) on the 6056-clause model. Fidget main branch: wgpu interpreter does not support register spills.
|
||||
- [doc] `culling-efficiency-oracle-prior-art.md` section 3 also records Keeter's text that many smooth blends "lead to less useful interval evaluation results" (MPR section 5) and that reduced affine arithmetic is named as a way "to more tightly bound intervals" (section 6).
|
||||
- [abs] None of the sources read for A1-A3 evaluates a chain of nested warps of noise. In every one, the chain is one expression (postfix in Mitchell, generated shader code in Knoll, flat tape in MPR/Fidget) and bounds come from per-node rules; no source reads as exposing "stage with its own bound" as a unit of composition except Zanni's node-level interpreter (blobtree nodes) and Segment tracing's per-operator Lipschitz rules (abstract only).
|
||||
|
||||
### A4. Closed-form span integration and pre-integration (Engel 2001)
|
||||
|
||||
- [src] Engel, Kraus, Ertl, *High-Quality Pre-Integrated Volume Rendering Using Hardware-Accelerated Pixel Shading* (HWWS 2001, pp. 9-16, `https://www3.cs.stonybrook.edu/~mueller/teaching/cse616/engel.pdf`, pdftotext read). The per-segment opacity and colour between a front sample sf and back sample sb over segment length d are precomputed into lookup tables of three parameters (sf, sb, d); the tables depend on the transfer function so "any modification of the transfer functions requires an update of the lookup tables" (section 3.5). Reductions: assume a constant segment length (2D table in sf, sb; "a good approximation for most perspective projections"), or approximate the d dependence as linear; local transfer function change recomputes only entries with sf <= s <= sb or the reverse; and the integral can be accelerated by cumulative integral functions (so each table entry is a difference of two 1D integrals). The paper states an analytic evaluation of the volume rendering integral "is possible in some cases, in particular for linear interpolation and piecewise linear transfer functions", "not feasible in general". Test hardware GeForce3 64 MB; the tables are lookups in the pixel shader.
|
||||
- [src] Same: the sampling of the scalar field along the ray is decoupled from the transfer function, so the Nyquist limit of the scalar field alone applies ("the Nyquist frequency for this sampling is not affected by the transfer functions"); the segment between the two samples is treated through the table, with the scalar assumed varying between sf and sb.
|
||||
- [web] (second-hand) Rottger et al. 2003, `https://diglib.eg.org/items/99a1d638-d8c8-4ad9-bd8d-1c041524c3e2` ("Smart Hardware-Accelerated Volume Rendering"): cited by a search summary for oversampling needed even with pre-integration; not read.
|
||||
- [abs] Engel's pre-integration is exact for the integral of the transfer function composed with the assumed in-segment interpolant of the scalar; the error against the true field is the interpolant error over the span (the thing the Span Lab's 0.5 K h^2 bounds). Engel's quantity that is tabulated is a function of the span endpoints' scalar values; the Span Lab's closed form is the clamp of a linear function integrated exactly (map Pass 8 `integral_clamp_linear`).
|
||||
|
||||
## Part B. site-ground-field
|
||||
|
||||
### B1. WebGL2 constraints for splat and kernel rendering
|
||||
|
||||
- [web] (search summary) WebGL2 has no compute shaders and no storage buffers; GPU sorting must be emulated by transform feedback or render-to-texture passes. Bitonic sort passes: "1/2 (log^2 n + log n)" passes, arithmetic heavy (a 2004 GPU particle engine paper, `https://www.cs.cit.tum.de/fileadmin/w00cfj/cg/Research/Publications/2004/GPU_Particle_Engine/uf_sig04_slides.pdf`, not read). A texture-based sort library's capacity is "max texture width squared", example 4096^2 = 16,777,216. I found no WebGL2 GPU radix sort implementation or measurement in this pass.
|
||||
- [web] Splat viewers in practice sort on the CPU in a worker and read order back to the GPU. antimatter15/splat (`https://github.com/antimatter15/splat`, README fetched): "splat sorting is done asynchronously on the cpu in a webworker", sort at roughly 4 fps while render is 60 fps, about 150 ms to sort about 1M splats on the author's machine, WebGL 1.0 "WebGL 2.0 adds nothing essential", demo about 1M splats; artifacts appear "when jumping between camera orientations on opposite sides, because the sort hasn't caught up". The author tried order-independent approaches: stochastic transparency looked grainy, weighted blended OIT "didn't work".
|
||||
- [doc] Spark (World Labs, three.js, WebGL2; `https://sparkjs.dev/docs/system-design/` and `https://sparkjs.dev/docs/performance/`): each viewpoint "reads back a list of all splat viewpoint distances from the GPU, then determines the splat draw order using an efficient bucket sort algorithm, run in a background worker thread", drawn in one instanced call, "the sort order lags the render by at least one frame, but possibly more on older devices" (search summary of the system-design page; a launch post says "two-pass radix sort in a Web Worker", sources disagree). Budgets (performance page): Quest 3 1M splats or less; Android phone 1-2M; iPhone 1-3M; computer 1-5M. Splats "must be blended back-to-front, which is the main reason rendering is demanding on mobile GPUs"; each splat is two triangles covering a Gaussian out to sqrt(8) standard deviations; ~1M splats make the per-splat transform overhead (SplatAccumulator) a bottleneck on some systems; "500K splats from a Trellis object at small screen scale can bottleneck GPU rendering and blending". `antialias` should stay false; high pixel ratio costs blending throughput.
|
||||
- [web] (search summary, PlayCanvas docs) default renderer sorts on the CPU in a worker; the GPU sort path is "experimental, and WebGPU only"; WebGL devices fall back to CPU sorting. A mobile 3DGS paper summary (`https://arxiv.org/pdf/2511.16298`) states sorting "dominates the computations in 3DGS on mobile platforms"; not read.
|
||||
- [src] `EXT_float_blend` (Khronos registry, `https://registry.khronos.org/webgl/extensions/EXT_float_blend/`): without it, blending with a 32-bit float draw buffer is `INVALID_OPERATION`; with it, drawing to 32-bit float buffers with blending enabled is allowed. The page does not address 16-bit float blending. `EXT_color_buffer_float` (`.../EXT_color_buffer_float/`) makes RGBA16F, RGBA32F and R11F_G11F_B10F color-renderable and says output is not clamped; the page does not address blending. [web] Esri (`https://www.esri.com/en-us/software-engineering/blog/articles/support-for-transparency-and-emissive-materials-on-limited-webgl-devices`): their float OIT needs EXT_float_blend, "not available on a significant subset of iOS devices"; float buffers cost 30 bytes/pixel for transparency plus emission, "about 110 MB on an 11-inch iPad"; their fallback "OIT8" packs accumulation into RGBA8 fixed point (each fragment divided by 16 on write, multiplied on composite): range 0-16, about 4 bits of precision per fragment, banding hidden by dithering, values overflow toward white after roughly 16 overlapping surfaces, emission capped at 16. No frame-time figures. I did not find a device-population percentage for EXT_float_blend or EXT_color_buffer_float (webglstats pages returned blog index text).
|
||||
- [web] Weighted blended OIT (McGuire and Bavoil 2013): two accumulation targets (accumulated premultiplied colour*weight and alpha product/revealage) plus a composite; sources describe it as order-independent, bounded memory, approximate ("fragment colors and depths are combined approximately", luma.gl docs; "not physically correct", Open CASCADE write-up). Cesium: needs only MRT and float textures. One third-party engine note reports poor handling of near-opaque layers (single report). Not read from the paper.
|
||||
- [web] Depth peeling (Everitt 2001): one geometry pass per layer; dual depth peeling N/2+1 passes; multi-layer depth peeling "up to 8x speed-up ... at the price of several MRT buffers". The only WebGL material found was a 2012 WebGL1 thread (no MRT, no MIN/MAX blend) and no WebGL2 measurement.
|
||||
- [web] Moment-based OIT (Munstermann, Krumpen, Klein, Peters, I3D 2018, `https://momentsingraphics.de/I3D2018.html`, `https://cg.ivd.kit.edu/mboit.php`, abstract and teaser read): works on the logarithm of transmittance so contributions sum additively; a first additive pass builds 4/6/8 power moments or up to 4 trigonometric moments; a second additive pass composites; storage 112 bits per pixel (six power moments) or 224 bits (three trigonometric); Peters' postscript notes half memory with rasterizer ordered views (not WebGL2). No WebGL2 port found. [web] Moment-based 3D Gaussian splatting (arXiv 2512.11800, summary read): order-independent, no sorting, two additive passes (moments, then quadrature of radiance) plus normalisation; trained with Vulkan/Slang, not WebGL; PSNR below sorted 3DGS on all three datasets (e.g. MipNeRF-360 25.96 vs 27.43) though qualitatively better on translucent overlaps per the authors; no frame rates reported.
|
||||
|
||||
### B2. 3DGRT and EVER
|
||||
|
||||
- [web] 3DGRT, Moenne-Loccoz et al., *3D Gaussian Ray Tracing* (arXiv 2407.07090, HTML fetched through a summariser, first 100 000 of 123 271 characters): BVH over particles each wrapped in a regular icosahedron (20 triangles) scaled to where response exceeds alpha_min = 0.01, hardware ray tracing; the any-hit program keeps the k = 16 closest hits by insertion, shaded in batches; hit ordering uses the bounding-primitive intersection distance, sample point is the analytic maximum along the ray. Table 2, one RTX 6000 Ada, fps: 3DGS 238 / 319 / 267 (MipNeRF360 / Tanks&Temples / Deep Blending); 3DGRT reference 55 / 143 / 77; 3DGRT 78 / 190 / 119. The text calls forward rendering "only about 2x" slower while the table implies about 3x to 4.3x. BVH build above 30 ms per million particles for icosahedra (Figure 9); training about 5x slower per step; memory 383 / 489 / 287 MB vs 3DGS checkpoint 763 / 422 / 704 MB. Generalised kernels (degree n=2... "generalized Gaussians") roughly double speed (277 vs 143 fps on Tanks&Temples). Limitations: slower than rasterisation for pinhole cameras, BVH rebuild for dynamic scenes, no automatic antialiasing, stochastic sampling prevents SSIM losses.
|
||||
- [web] EVER, Mai et al., *Exact Volumetric Ellipsoid Rendering for Real-time View Synthesis* (arXiv 2410.01804, HTML fetched through a summariser): constant-density ellipsoids each with a single (view-dependent) colour; between consecutive ray intersection points colour and density are constant so the volume rendering integral is evaluated in closed form; overlap handled by tracking running density and premultiplied colour totals as the ray enters and exits each ellipsoid; per-ray sorting via NVIDIA OptiX BVH (rebuilt each training step, 2-20 ms), multi-hit queue borrowed from 3DGRT. RTX 4090 at 720p unless noted: about 36 fps Mip-NeRF 360 outdoor, 66 indoor, 30 Zip-NeRF; at dataset test resolution 36 / 24 / 44 fps vs 3DGS 224 / 559 / 560; tracing 20-71 ms per frame; "about 10x slower than 3DGS and uses more memory"; densification cap 7M primitives. Model is emission and absorption only, no scattering; popping metric LIP7 0.0 vs 0.0315 for 3DGS on Tanks&Temples. Anisotropic rotated ellipsoids enlarge their bounding boxes; an anisotropy regulariser trades quality for speed.
|
||||
- [web] WebGPU ray tracing status (search summary, non-authoritative blogs): hardware ray tracing and ray query "not yet part of the WebGPU specification, though proposals exist"; no browser shipping it found; a software compute-shader tracer exists (WebRTX, Chrome-tested, unstable spec). No WebGL2 route to either 3DGRT (OptiX/RT cores) or EVER.
|
||||
- [doc] The committed probe's kernel cascade is a CPU C# ray walker (hash grid, Amanatides-Woo DDA, early exit at sum of tau >= 7) over compact-support kernels with closed-form line integrals (`integration-native-noise-domain.md`, sections "Composition" and "Measurements"). Its measured cost ratios are CPU single-thread ratios against the march, same run: void L2 0.48x, L3 1.24x, L4 1.81x, L6 2.57x of the march cost; wells L2 0.23x, L6 1.20x; integrals are about 0.4 us of a 74 us pixel and traversal dominates (93 roots visited for 8 hits at L6). Kernel Lab page (`c09d2762`) is described in `eyes-nebula-labs/facts.md` section 1b: budget KBUDGET 420000 kernels and IDXCAP 4096*3072 bin entries, two-phase crossfade (2 copies per kernel). This pass found no WebGL2 measurement of either field.
|
||||
|
||||
### B3. Comparison basis: marched procedural field (span framework) vs kernel cascade, orbiting-camera backdrop
|
||||
|
||||
- [doc] Site constraints from the file-era spec (`F:\Projects\CultLib\docs\cultmath-interval-ground-cut-site-ground.r3.spec.json`, never run): fullscreen WebGL2 backdrop behind the masthead; sun orbit period 72 s, camera follows low inside the hollow; half-float history for TAA; shared fallback path for "slow device, hidden tab, reduced motion, missing WebGL2 and missing half-float"; per-frame sample cap S_budget and a controller that adapts S and dpr to measured GPU ms; the operator-verification list includes the phone ("read the logged GPU ms per pass and the controller's final S/dpr; say whether it got warm after five minutes"). The CSS wash is the stated fallback ground. `gamecult-site` `origin/main` `af6e206`: no ground shader, no vendored CultMath GLSL (map Pass 8).
|
||||
- [abs] Per-frame cost scaling. Marched field (full-screen fragment work): cost ~ pixels x samples per pixel x field cost per sample; the Span Lab replaces fixed steps by spans, so samples ~ spans per ray, each span a chain evaluation (value, Jacobian, bound) rather than one field sample (the lab's `maxSpans` cap 8..1000, default 400; its `tol` controls span count; `facts.md` section 3). Cost is independent of any scene object count, so fill rate (pixels x dpr) is the knob; the r3 spec scales dpr and S. Kernel cascade as rasterised splats: vertex work ~ kernel count K (sort and transform, Spark: ~1M is a bottleneck on some systems), fragment work ~ sum over kernels of covered pixels (overdraw ~ kernels per pixel x pixels; the probe's CPU walk saw 20-36 kernels per pixel at L2-L6 on the void), blend traffic on a float target per fragment (16F or 32F per B1). Cost scales with detail level (kernels per level multiply in the cascade: 8, 12, 8, 7), the frame knob is kernel LOD plus resolution scale.
|
||||
- [abs] Memory. Marched field: no per-scene geometry; state is uniforms (the lab's SPEC has about 60 scalars), a 4 Rw flow texture in r3, TAA history textures (colour, transmittance, depth), pre-pass mask. Kernel cascade: per-kernel record (the lab bakes centre, axes, peak, tint, flow vector, children indices; `eyes-nebula-labs/facts.md` section 1b), caps in the lab: KBUDGET 420 000 kernels; GPU upload as textures or instance buffers; plus the sort/order buffer if emission is order-dependent and any OIT accumulation targets.
|
||||
- [abs] Authoring model. Marched / span framework: authored field is a function (brushes, ramp, warp chain with parameter block) evaluated per point or span; edits are parameter changes with no rebuild; bounds must be derivable for each stage or the span evaluator is blind (Part A). Kernel cascade: authored as generator parameters (levels, children, keep, asp, rot, seed) that rebuild the kernel set on CPU on change ("Cascade items trigger rebuild", Kernel Lab SPEC); the set is explicit data, so the same data can be exported and no per-pixel bound derivation is needed, but fibrous look is the cascade's own ("fibrous look, cannot be marched" per the question text) and a field defined by a function (e.g. a warp chain) has no kernel representation in these sources.
|
||||
- [abs] Transmittance and emission under additive blending (reasoning, not tested). For absorption only, T = exp(-sum_k tau_k), where tau_k is the closed-form line integral of kernel k along the pixel's ray; a sum is order independent, so additive blending into a float or fixed-point target gives T exactly up to precision (B1 notes: 32F blend needs EXT_float_blend, which is absent on some iOS devices; 16F blending is not addressed by the two Khronos pages read; RGBA8 fixed-point packing overflows after about 16 overlaps in Esri's scheme). Emission integral of e(t) T(t) along the ray depends on T at each depth, so ordered compositing is needed (sorted splats, or OIT approximations: WBOIT, moment OIT, depth peeling at N passes) or a single-scatter approximation that drops the T(t) factor. EVER (B2) is the cited exact treatment of this ordering problem, by per-ray sorted traversal with RT cores.
|
||||
- [abs] Neither approach in B2 sources has been run in WebGL2 in this pass. No fps number in this file comes from a WebGL2 run.
|
||||
|
||||
## Gaps
|
||||
|
||||
- Taylor models: no primary Makino-Berz composition theorem text read (only Neumaier's review, search summaries of Makino's slides and the Caltech convergence paper); no remainder-growth-per-stage numbers for a chain of noise-like functions found; no graphics paper using Taylor models for ray marching found.
|
||||
- Knoll Table 1 row mapping not re-derived; Galin et al. per-operator Lipschitz rules and query counts not read (PDF behind login to curl); Fidget/MPR chain composition covered only by the committed doc; no source found that composes bounds across warp stages of noise.
|
||||
- WebGL2: no device-share numbers for EXT_float_blend, EXT_color_buffer_float or 3D texture size; no WebGL2 GPU radix sort source; no WebGL2 depth-peeling or WBOIT measurement; 16F blending support not confirmed from a spec page; 3DGRT and EVER read through summarisers (EVER and 3DGRT HTML not read to the end).
|
||||
- No WebGL2 measurement of the Kernel Lab or the Span Lab on a phone exists in the sources read; comparison part B3 is [abs] plus the CPU ratios in the committed probe doc.
|
||||
82
docs/research/tape-register-model.md
Normal file
82
docs/research/tape-register-model.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Facts for `cultmath-tapes:question:tape-register-model` (2026-10-14)
|
||||
|
||||
Eyes, read only. Tags: [src] source read, [doc] vendor/library documentation, [web] web page or search summary (second-hand where noted), [abs] derived by reasoning or a synthetic probe, not a measurement of any real GPU. Raw downloads: `raw/` beside this file (Fidget files at `main` on 2026-10-14; MPR paper text). Probe: `probe2.py` beside this file.
|
||||
|
||||
## 1. Fidget and MPR
|
||||
|
||||
### 1a. Fidget tape, allocator, bytecode (`github.com/mkeeter/fidget`, raw at `raw.githubusercontent.com/mkeeter/fidget/main/`)
|
||||
- [src] The allocator is `fidget-core/src/compiler/alloc.rs`, `RegisterAllocator<const N: usize>`, doc "Cheap and cheerful single-pass register allocation" (`:11`). `assert!(N <= u8::MAX)` (`:51`). It keeps a least-recently-used list of registers (`register_lru`) and the output tape is "assembled in reverse order". When no register is free it spills to a memory slot (`Allocation::Memory(u32)`, `RegOp::Load/Store`); memory is "treated as unlimited" (`get_memory`, `:106-123`). So the allocator is not a cap-and-refuse design: the CPU/JIT target spills past N. (I did not read the walk direction beyond the "reverse order" comment; "linear scan from the tape end" is consistent with it, not confirmed line by line.)
|
||||
- [src] Bytecode, `fidget-bytecode/src/lib.rs:1-45`: little-endian `u32` words, two words per op (fixed length so it can be walked forwards and backwards). Word 0 bytes: opcode, output register, input register 1, input register 2; input byte `0xFF` means "use word 1 as an f32 immediate". Tape starts `0xFFFFFFFF 0x00000000`, ends `0xFFFFFFFF 0xFFFFFFFF`. `Mem` op (Load/Store) uses word 1 as a memory slot. Register 255 is reserved (`ReservedRegister` error), so at most 255 registers in bytecode. The format is stated "not stable". Registers are renumbered by frequency of use (most used = 0). `Bytecode` records `reg_count` and `mem_count`.
|
||||
- [src] `fidget-wgpu/src/shaders/tape_interpreter.wgsl` (115 lines): `fn run_tape(...)` declares `var reg: array<Value, REG_COUNT>;` as a function-scope (private, per-invocation) array, indexed at runtime by bytes from the op word (`reg[lhs_i]`, `reg[op[1]] = tmp`). Not workgroup memory. `REG_COUNT` is a WGSL `const` the host prepends per pipeline (`fidget-wgpu/src/pixel/mod.rs:37,54,71`: `const REG_COUNT: u32 = {reg_count};`).
|
||||
- [src] `fidget-wgpu/src/lib.rs:529-545`: `REG_PIPELINE_SIZES = [8, 16, 32, 64, 128, 192, 255]`; `RegPipeline::get(reg_count)` compiles (lazily, cached) the first pipeline whose size >= the tape's register count and panics above 255. So the GPU interpreter specialises the shader on register-array length in seven buckets, so small tapes do not pay for a 255-element array.
|
||||
- [src] The same interpreter does NOT support spills: `case OP_MEM: { // Not implemented! return out; }` (op switch in `tape_interpreter.wgsl`). So on the GPU the effective cap is 255 registers and a tape needing memory slots does not run correctly. Related capacity: `TAPE_DATA_CAPACITY` = 8M words, 64 MiB (`lib.rs:268`).
|
||||
- [src] Registers hold `Value` (shared across interval, float and gradient evaluation). Pixel workgroups are `@workgroup_size(8, 8)` (`pixel/shaders/pixel_tiles.wgsl:7`, `interval_tiles.wgsl:13`). No `var<workgroup>` register store in those two shaders (grep found none).
|
||||
- [src] A second per-thread private structure: the min/max choice `Stack` is `array<u32, 32>` per invocation, 2 bits per choice, 512 choices (`shaders/stack.wgsl:1-12`).
|
||||
- [web] I found no published performance numbers for `fidget-wgpu` (its README is 9 lines: "implements 3D rendering of Fidget tapes using WebGPU"). CultLib's own note: `F:\Projects\CultLib\docs\cultmath-tape-target.md:25-40` (Fidget 0.5.1; tape 254 clauses shrinks to about 20 at pixel level, Fidget's example).
|
||||
|
||||
### 1b. MPR (Keeter, ACM TOG 39(4), SIGGRAPH 2020; `https://www.mattkeeter.com/research/mpr/keeter_mpr20.pdf`, text in `raw/mpr.txt`)
|
||||
- [src] Section 2: each thread "has exclusive access to a block of memory"; clause arguments and outputs are indexes into it, "slots", "equivalent to machine registers". Slot allocation is "a simple greedy algorithm": a slot is free outside its value's live range; if an argument is the last use, its slot is freed before the output is assigned (a clause may read and write the same slot). Register allocation with reordering (Rawat et al. 2018) was "deemed outside the scope".
|
||||
- [src] Section 4 implementation list: clause = 8 bytes (opcode 1 B, output slot 1 B, two input slots 1 B each, or one slot + 4 B immediate); "The storage slots are per-thread arrays of 128 values. For simplicity, this is hard-coded, but could be dynamically sized with the addition of load/store opcodes." The choices stack is a per-thread array too (2 bits per choice, 4096 choices in 256 `uint32_t`). Implementation was CUDA; tape in global memory.
|
||||
- [src] Table 2 (clauses, slots, ratio): "Hello, world" 328 / 26 / 12.6; bear head 541 / 22 / 24.6; architectural model 961 / 75 / 12.8; 2D text benchmark 6056 / 84 / 72.1. The paper hypothesises the high text ratio is because each character is an independent CSG primitive.
|
||||
- [src] Interpreter overhead (section 2.1, GTX 1080 Ti): brute-force interpreter about 58 ms per megapixel versus about 3 ms/MP for the same expression pasted into a CUDA function and compiled with nvcc (about 19x), on the 6056-clause text model; nvcc compile took 3.45 s. The full algorithm (interval pruning, tape shortening) beats the compiled kernel above about 1536^2 pixels and stays below 8 ms/frame at 4096^2. The paper reports no separate measurement of the 128-slot array's local-memory cost.
|
||||
- [src] Tape generation on CPU: 7 ms for the text model, 1-2 ms for the others.
|
||||
|
||||
## 2. GPU cost of dynamically indexed locals, groupshared, unrolled registers
|
||||
|
||||
- [doc] NVIDIA CUDA Programming Guide (`https://docs.nvidia.com/cuda/cuda-programming-guide/02-basics/writing-cuda-kernels.html`, via search summary): the compiler is likely to place arrays in local memory when it cannot prove constant indices; local memory physically lives in global memory space. The Nsight Compute profiling guide says the same.
|
||||
- [web] NVIDIA developer blog "Fast Dynamic Indexing of Private Arrays in CUDA" (`https://developer.nvidia.com/blog/fast-dynamic-indexing-private-arrays-cuda/`, fetched): constant indices keep the array in registers; dynamic indexing forces local memory, cached in L1/L2. Measured on Tesla K20, 32-element float private array: non-uniform indexing in local memory 24x slower than uniform indexing in local memory; non-uniform indexing in shared memory 1.34x faster than uniform local. On Maxwell: local non-uniform vs uniform gap about 14x; the shared-memory version 4x faster than uniform local. Replays per warp: 0 if all lanes use the same index, about 2.5 average for uniformly random indices over 0..31, up to 31 if every lane differs. Suggests a math-to-memory ratio of roughly 4:1 to 8:1 to hide local-memory cost. Shared layout: element i of thread t at `i * BLOCK + t` (one bank per thread); block size a multiple of 32; at most 48 KB shared per block; technique suited to private arrays of about 30-50 32-bit elements, larger arrays reduce occupancy.
|
||||
- [web] AMD (search summary of ROCm docs and a 2012 GCN thread): stack arrays live in scratch memory by default and the compiler may promote them to registers; GCN VGPRs can be indexed but one contributor reported about 50% speedup moving to static registers in some kernels (anecdote, old arch: `https://www.openwall.com/lists/john-dev/2012/12/09/7`). LLVM has a flag `-amdgpu-use-divergent-register-indexing`, so divergent VGPR indexing is a compiler mode. Register budget vs occupancy: RDNA SIMD register file 128 KB (GCN 64 KB); RDNA 4 desktop 192 KB per SIMD, full thread slots at 96 or fewer vector registers (`https://chipsandcheese.com/p/dynamic-register-allocation-on-amds`, `https://rocm.blogs.amd.com/software-tools-optimization/register-pressure/README.html`).
|
||||
- [web] Intel oneAPI GPU optimization guide, "Optimizing register spills" (`https://www.intel.com/content/www/us/en/docs/oneapi/optimization-guide-gpu/2024-2/optimizing-register-spills.html`): spills are likelier on Intel than NVIDIA because the GRF is smaller (A100 up to 256 32-bit registers per work-item vs 64 by default on Data Center GPU Max 1550); advice to avoid big private arrays and use shared local memory. A 2013 Intel SDK note says indexed private memory is slow because each work-item touches a unique cache line (old, OpenCL).
|
||||
- [web] Mesa/NIR driver commits (search summary): drivers choose by a size threshold between lowering indirect array access to conditional register selects and lowering to scratch. Intel commit "intel/fs: Lower large local arrays to scratch": on Kaby Lake, helped 6, hurt 2, helped programs were Aztec Ruins compute shaders, overall confidence interval for cycles included zero. An AMD MR found a 16-scalar-element threshold caused more scratch use. V3DV keeps arrays larger than 2 elements in scratch. SPIR-V allows dynamic indexing of arrays in every storage class except PushConstant (Khronos slide deck). Links: `https://gitlab.freedesktop.org/mesa/mesa/-/merge_requests/1097`, `https://gitlab.neroreflex.duckdns.org/NeroReflex/mesa/commit/69244fc7`.
|
||||
- [doc] D3D: `dcl_indexableTemp x0[23], 2` declares an indexable temp array; element count of all temp registers (indexable and not) cannot exceed 4096 (`https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/dcl-indexabletemp`). FXC names a locally indexed array `x#`, each its own namespace (`https://list.winehq.org/hyperkitty/list/wine-gitlab@list.winehq.org/message/KNVE3CV4O2D464TKNCRIMZA6B7QG54NP/`). I found no measured cost of `x#` access on any GPU; whether a driver puts an `x#` array in registers or scratch is the driver's choice (vendor behaviour above).
|
||||
- [web] FXC limitation: writing to a dynamically indexed fixed-size array inside a struct fails (X3500 "array reference cannot be used as an l-value; not natively addressable"); Tint copies the array to a local, writes, copies back (`https://dawn.googlesource.com/tint/+/refs/heads/chromium/4765%5E%21/`). Relevant if the register array sits in a struct.
|
||||
- [doc] HLSL `groupshared`: compute shaders only; D3D10 limit 16 KB, D3D11 limit 32 KB total per thread group (`https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/dx-graphics-hlsl-variable-syntax`). [abs] 32 KB / (N registers x 4 B) = threads per group: N=64 gives 128 threads; N=128 gives 64; N=255 gives 32 (a float2 interval register halves each).
|
||||
- [abs] Static indexing needs the register number to be a compile-time constant, which in an interpreter loop it is not. A fully unrolled generated kernel has constant registers; that is MPR's "compiled expression" case (3 ms/MP vs 58 ms/MP interpreted, 1080 Ti).
|
||||
|
||||
## 3. Other GPU interpreters and expression VMs
|
||||
|
||||
- [web] Unity Shader Graph: the node graph is emitted as HLSL; the Custom Function node injects HLSL text or an include (`https://docs.unity3d.com/Packages/com.unity.shadergraph@17.2/manual/Custom-Function-Node.html`). The docs I read do not describe a runtime interpreter; the internals of the generator are not stated there, so "no interpreter" is consistent with the docs, not confirmed by them. Built-in pipeline support in Shader Graph is "compatibility only" since 2021.2 and, in the 17.5 docs, deprecated, supported through the Unity 6.7 LTS lifecycle (`https://docs.unity3d.com/Packages/com.unity.shadergraph@17.5/manual/Graph-Target.html`). Shader Graph docs found say nothing about compute shaders.
|
||||
- [web] Dreams (Media Molecule): Alex Evans, SIGGRAPH 2015 "Learning from Failure" (`https://advances.realtimerendering.com/s2015/AlexEvans_SIGGRAPH-2015-sml.pdf`, `https://www.mediamolecule.com/blog/article/siggraph_2015`). Secondary summary: edit lists of SDF primitives evaluated hierarchically into cached 8x8x8 bricks. The slides exceeded the fetch size cap (10 MB), so not read; no source found for a bytecode interpreter or its register storage in Dreams. GAP.
|
||||
- [web] vyre crate (`https://docs.rs/crate/vyre/0.3.0`): compiles boolean rule conditions to bytecode (49 opcodes) run on compute shaders, one rule per thread; no register layout described.
|
||||
- [src] Per `F:\Projects\CultLib\docs\cultmath-interval-ground-cut.md:1727-1731`, `F:\Projects\aetheria-material-path-prior-art.md` section 4 holds Dolphin ubershaders and Laine et al. on megakernel register pressure; "no measured interpreter-versus-kernel timing was found". I did not re-read that file.
|
||||
- [web] Divergence: an interpreter loop dispatches on data-dependent opcodes; Mesa ACO notes divergent control flow executes both paths with lanes masked (`https://android.googlesource.com/platform/external/mesa3d/+/b2fa2d99ae2/src/amd/compiler/README.md`). MPR makes every thread of a warp evaluate the same shortened tape (MPR section 4). [abs] A fixed trophic graph evaluated per point has the same property: all lanes run the same tape.
|
||||
- Gaps: no hit for libfive GPU work other than MPR itself; no IQ note on bytecode interpreters; no published shadertoy-style VM register layout; no shipped-game example of a bytecode interpreter in a shader found.
|
||||
|
||||
## 4. Register counts for the trophic graph
|
||||
|
||||
- [src] Aetheria `4354ef2c` (`docs/aetheria-fauna-map.md` B28-B36; `docs/research/2026-10-14-trophic-dag-probe/README.md`) gives layers and costs but no node or register count. B36: "each ring-stencil level multiplies the cost by 9" (krill .26 ms, minnows 3.19 ms = 9 krill, sharks 29.3 ms = 81 krill, Python single evaluation); stencils at most two levels deep (target invariant `trophic-dag-pointwise`). Probe layers: L1-glim, L2-krill (Skein x Holling of glim), L2-minnows (Holling of krill over the point + 8 taps on a 48 u ring), L3-sharks (minnows over a 160 u ring x band), L3-lanterns (slack x krill over a 120 u ring). No tape exists yet.
|
||||
- [src] `F:\Projects\CultLib\docs\cultmath-interval-ground-cut.md` Pass 7, T7 (`:1677`): in a graph a stencil is "input substitution" (the prey subgraph instantiated per tap with the position input replaced); the builder hash-conses identical nodes, so the 9 taps do not share nodes (their inputs differ). T7(b): site-span ops read an array of wells (per-batch buffer input). T7(c): the lattice weight is evaluated at the site, not the query point. Multi-result ops (cellular) are read through `Pick(node,k)`.
|
||||
- [src] CultMath already carries a multiplicatively weighted lattice fragment: `packages/cultmath/src/CultMath/Voronoi.cs:151-154` (`weight = o.x*0.5f+0.5f`, `d = max(d2.x,d2.y)*weight`) and an extern `cultmath_apollonian_voronoi_tones` (`:219`).
|
||||
- [abs] Synthetic probe `probe2.py` (Python; greedy MPR-style slot reuse, free at last use): a 9-tap mean-of-mean tree whose leaf is a 12-op chain with 2 live values. Slots depend on the schedule order, not on op count:
|
||||
|
||||
| stencil levels | ops | slots, depth-first with running accumulator | slots, level-order |
|
||||
|---|---|---|---|
|
||||
| 0 | 13 | 2 | 2 |
|
||||
| 1 | 125 | 3 | 18 |
|
||||
| 2 | 1,133 | 4 | 149 |
|
||||
| 3 | 10,205 | 5 | 1,230 |
|
||||
|
||||
A variant that computed all 9 child results before summing gave 10 / 18 / 26 slots at levels 1 / 2 / 3 (+8 per level; first probe script `probe.py`). The tree is a toy (no shared subexpressions, no span inputs, no wide intermediate such as cellular's multi-result values); real counts need the real graph. For scale, MPR's measured counts are 22 to 84 slots for 328 to 6056 clauses (ratio 12.6 to 72.1).
|
||||
- [abs] Fidget's frequency renumbering and the 255 cap mean the host knows `reg_count` before dispatch and can choose among pipelines (1a).
|
||||
|
||||
## 5. Scope addition: gravity bodies as Voronoi sites (operator sketch)
|
||||
|
||||
### 5a. Diagram variants over a small set of explicit weighted sites
|
||||
- [web] Wikipedia "Weighted Voronoi diagram" (`https://en.wikipedia.org/wiki/Weighted_Voronoi_diagram`, fetched): multiplicatively weighted = distance divided by a positive weight; boundaries circular arcs and straight segments in the plane; "A Voronoi cell may be non-convex, disconnected and may have holes". Additively weighted = weights subtracted from distance; boundaries hyperbola arcs and straight segments. Power diagram = weights subtracted from squared Euclidean distance. (The page does not state the Apollonius-circle name, convexity or the Delaunay relation; I did not source those.)
|
||||
- [abs] Equal-potential boundary: for point masses with potential `-G*M/r`, the locus where two bodies' potentials are equal has `r1/r2 = M1/M2`, a multiplicatively weighted Voronoi edge (weight = mass), an Apollonius circle in 2D. The probe's `slack` (weight = gravity radius, citing TD:20 Apollonius circles) is the same family. A sum of potentials `sum G*M_i/r_i` is not a Voronoi cell; its level sets and the points where pulls cancel are different objects (`slack` in the probe is "to first order" `(F2-F1)/|grad(F2-F1)|`). Equal-force (`M/r^2`) boundary: `r1/r2 = sqrt(M1/M2)`, multiplicative with weight `sqrt(M)`.
|
||||
- [src] Aetheria probe measurements (`docs/aetheria-fauna-map.md` B33-B34): the hard Gabriel graph over six bodies changes its edge set 29 times in 1,400 s (one gas-giant orbit), each a strand appearing or vanishing whole; the soft gate moves at most .00075 per 1/60 s. Largest presence step under the krill carriage: band .0035, slack .013, skein .054, lattice .124, skein-web .493.
|
||||
- [abs] Pop properties when a site moves: the distance field `F1` and the border measure `F2 - F1` are continuous in site position and weight; what jumps is the discrete label (which site is nearest) and adjacency (which cells touch). For multiplicatively weighted cells a cell can appear, vanish, split or become disconnected as weights change (Wikipedia above). With six bodies, a loop over all sites per point is exact; a lattice-based diagram needs its search window sized to the weight range (Aetheria B34: 5x5 sufficed for a 1.6x boost).
|
||||
- [web] Cosmic-web analogues: Hidding, van de Weygaert, Vegter, Jones, "Adhesion and the Geometry of the Cosmic Web" (arXiv 1211.5385) and van de Weygaert, "Tessellating the Universe: the Zel'dovich and Adhesion tiling of space" (arXiv 0912.4520, abstract read): the adhesion model (Zel'dovich approximation plus viscosity, Burgers' equation) yields in the zero-viscosity limit a "quasi-Voronoi" tessellation, with the Eulerian/Lagrangian relation tied to Voronoi/Delaunay duality. In that tessellation, 1-dimensional edges correspond to filaments (second most massive elements), vertices to clusters/nodes (most mass), faces/tiles to walls and voids. Voronoi foam kinematic model: matter condenses onto Voronoi walls, edges and nodes (search summary, arXiv 1611.01221 area). The abstract of 0912.4520 does not mention weighted or power diagrams or the distance metric; I found no source tying a weighted Voronoi to the cosmic web. Aetheria already cites a Zel'dovich warp (TD:39).
|
||||
- [abs] In 3D the Voronoi border set is faces (walls) meeting in edges (filament-like) at vertices; in 2D the borders are edges and the vertices are the node-like points. A rendered "filament" from a diagram's border in 3D is wall-like away from the edges.
|
||||
- [abs] Anisotropic/flow-stretched metric `d = |A(x-y)|` with constant per-site A elongates cells along A's long axis; a metric varying along the path is a geodesic with no closed form. Not sourced.
|
||||
|
||||
### 5b. Metric as a node parameter
|
||||
- [doc] Blender Voronoi Texture node (`https://docs.blender.org/manual/en/4.2/render/shader_nodes/textures/voronoi.html`, search summary): Distance metric Euclidean, Manhattan, Chebychev, Minkowski with an Exponent input; exponent 1 = Manhattan, 2 = Euclidean, infinite = Chebychev; manual examples use 0.5, 1, 2, 32 and treat 32 as an approximation of Chebychev; exponent below 1 gives star shapes. Features: F1, F2, Smooth F1, Distance to Edge, N-sphere Radius.
|
||||
- [src] Cycles implementation (`intern/cycles/kernel/svm/voronoi.h`, fetched from `raw.githubusercontent.com/blender/blender/main/`): `voronoi_distance(a,b,params)` is a template selecting with a chain of `if` on `params.metric`: Euclidean `distance`, Manhattan `reduce_add(fabs(a-b))`, Chebyshev `reduce_max(fabs(a-b))`, Minkowski `powf(reduce_add(power(fabs(a-b), exponent)), 1/exponent)`; unknown metric returns 0. `voronoi_distance_bound` is a cheaper variant that skips the square root (Euclidean) and the final root (Minkowski) for ordering inside the search loops; the true distance is computed once for output. Search windows: 2D 3x3 for F1/F2/edge/n-sphere, 5x5 for Smooth F1; 3D 3x3x3 (27 cells) and 5x5x5 (125) for smooth; 4D 81 and 625. A header comment records the non-smooth windows were reduced from -2..2 to -1..1 for performance.
|
||||
- [web] Houdini `mx_voronoi` VEX: integer `metric` argument, 0 Euclidean, 1 squared distance, 2 Manhattan (a mirror lists 3 Chebyshev) (`https://www.sidefx.com/docs/houdini/vex/functions/mx_voronoi.html`). Substance 3D Designer "Distance" node: Euclidean, Manhattan, Chebyshev (`https://experienceleague.adobe.com/en/docs/substance-3d-designer/using/substance-graphs/nodes-reference-for-substance-graphs/atomic-nodes/distance`); I did not find the Cells node's own metric options. 3Delight: Minkowski 1 = Manhattan, 2 = Euclidean, 4 rounded square, infinity Chebyshev (`https://documentation.3delightcloud.com/x/TgYuMg`).
|
||||
- [abs] Cost of a metric switch: Cycles' form is a runtime branch on a parameter uniform across a dispatch (all lanes take the same branch). A Minkowski exponent as a float input costs a `pow` per site pair plus one `pow` for the root; the bound variant avoids the root in loops. In a tape interpreter a constant metric can be chosen at lowering (ops in the graph) or dispatched at run time as one node.
|
||||
|
||||
### Gaps (section 5)
|
||||
- No source measured the per-evaluation cost of a metric switch; only the Cycles code structure is read.
|
||||
- No source found for weighted/power diagrams in cosmic-web modelling; the equal-potential = multiplicatively-weighted identity is derived here, not cited.
|
||||
- No numeric probe of cell geometry was run.
|
||||
52
docs/research/tape-register-model/probe2.py
Normal file
52
docs/research/tape-register-model/probe2.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Register need of a stencil tree under two schedules; MPR-style greedy slot reuse (free at last use).
|
||||
import sys
|
||||
LEAF=12 # ops in a shared-input leaf subgraph chain (krill-like): leaf = chain with 3 live temps
|
||||
def build(levels, taps=9):
|
||||
# node: ('leaf',) or ('mean', [children]) ; each tap child is an independent subgraph (offset input substituted)
|
||||
nodes=[] # (operands)
|
||||
def leaf():
|
||||
# leaf subgraph: LEAF ops, each uses previous two results (width 2 live) -> 2 live values
|
||||
base=len(nodes); nodes.append([]) # input
|
||||
prev=[base]
|
||||
for i in range(LEAF):
|
||||
ops=prev[-2:]
|
||||
nodes.append(ops); prev.append(len(nodes)-1)
|
||||
return prev[-1]
|
||||
def mean(l):
|
||||
if l==0: return leaf()
|
||||
acc=mean(l-1)
|
||||
for _ in range(taps-1):
|
||||
c=mean(l-1)
|
||||
nodes.append([acc,c]); acc=len(nodes)-1
|
||||
return acc
|
||||
root=mean(levels)
|
||||
return nodes,root
|
||||
def regs(nodes,order):
|
||||
pos={n:i for i,n in enumerate(order)}
|
||||
last={}
|
||||
for n in order:
|
||||
for o in nodes[n]: last[o]=max(last.get(o,-1),pos[n])
|
||||
live=0;peak=0;free=0;alloc=0
|
||||
for n in order:
|
||||
# free operands whose last use is here
|
||||
for o in set(nodes[n]):
|
||||
if last[o]==pos[n]: free+=1
|
||||
if free: free-=1
|
||||
else: alloc+=1
|
||||
if n not in last: free+=0
|
||||
return alloc
|
||||
def dfs_order(nodes):
|
||||
return list(range(len(nodes))) # construction order is postorder DFS
|
||||
def bfs_order(nodes,root):
|
||||
# level order by depth from root (reverse), a bad schedule
|
||||
depth={root:0}
|
||||
users={}
|
||||
for n,ops in enumerate(nodes):
|
||||
for o in ops: users.setdefault(o,[]).append(n)
|
||||
d=[0]*len(nodes)
|
||||
for n in range(len(nodes)-1,-1,-1):
|
||||
d[n]=max([d[u]+1 for u in users.get(n,[])],default=0)
|
||||
return sorted(range(len(nodes)),key=lambda n:(-d[n],n))
|
||||
for L in range(0,4):
|
||||
nodes,root=build(L)
|
||||
print(L,"ops",len(nodes),"slots_dfs",regs(nodes,dfs_order(nodes)),"slots_levelorder",regs(nodes,bfs_order(nodes,root)))
|
||||
|
|
@ -33,9 +33,20 @@ shader semantics.
|
|||
- `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.
|
||||
It includes `shaders/CultMath.Phacelle.hlsl` (MPL-2.0) and
|
||||
It includes `shaders/CultMath.Phacelle.hlsl` (MPL-2.0),
|
||||
`shaders/CultMath.Cycles.hlsl` (Apache-2.0) and
|
||||
`shaders/CultMath.Interval.hlsl` and `shaders/CultMath.Affine.hlsl` from the same
|
||||
directory, so ship all four.
|
||||
directory, so ship all five.
|
||||
- Cycles node semantics (Apache-2.0): `cycles_*` functions that compute what
|
||||
Blender's Cycles renderer computes for the Math node operations whose results
|
||||
differ from a plain intrinsic (`cycles_divide`, `cycles_power`, `cycles_modulo`,
|
||||
`cycles_wrap`, `cycles_pingpong`, `cycles_smooth_min`, ...), Map Range
|
||||
(`cycles_map_range_linear`, `_stepped`, `_smoothstep`, `_smootherstep`), Clamp
|
||||
(`cycles_clamp_minmax`, `cycles_clamp_range`), Mix (`cycles_mix_float`,
|
||||
`cycles_mix_vector` and every colour blend mode as `cycles_mix_<mode>`), RGB to BW,
|
||||
and `cycles_ramp_index`, where a Color Ramp reads its table. `cultmath_cycles_*` in
|
||||
HLSL and GLSL. Operands are finite floats, and the tests hold every function to
|
||||
the results of Blender 5.2.2's own CPU Cycles (`tools/cycles-oracle`).
|
||||
- interval arithmetic for culling empty space: `iv_*` functions
|
||||
over `float2(lo, hi)` (`iv_add`, `iv_mul`, `iv_exp`, `iv_smoothstep`, ...)
|
||||
and `iv_snoise_ball`/`iv_fbm_ball`, which bound `snoise` and `fbm_grad` over a
|
||||
|
|
@ -58,12 +69,24 @@ shader semantics.
|
|||
- `shaders/CultMath.glsl` (MIT), the same `cultmath_*` library in GLSL ES 3.00
|
||||
for WebGL2, generated from `CultMath.hlsl` (never edited by hand), and
|
||||
`shaders/CultMath.Phacelle.glsl` (MPL-2.0), generated from
|
||||
`CultMath.Phacelle.hlsl`, which holds `cultmath_phacelle` alone. GLSL has no
|
||||
`CultMath.Phacelle.hlsl`, which holds `cultmath_phacelle` alone, and
|
||||
`shaders/CultMath.Cycles.glsl` (Apache-2.0), generated from
|
||||
`CultMath.Cycles.hlsl`, which holds the `cultmath_cycles_*` functions. GLSL has no
|
||||
`#include`: a WebGL consumer concatenates `CultMath.glsl` after its own
|
||||
`#version 300 es` line and `precision highp float; precision highp int;`
|
||||
(the library declares no precision, and its hashes need 32-bit integers),
|
||||
then `CultMath.Phacelle.glsl` only if it calls `cultmath_phacelle`, then its
|
||||
then `CultMath.Phacelle.glsl` only if it calls `cultmath_phacelle` and
|
||||
`CultMath.Cycles.glsl` only if it calls a `cultmath_cycles_` function, then its
|
||||
own shader source.
|
||||
- Span primitives (`math.Span.cs`, `cultmath_*` in HLSL and GLSL): `snoise_hessian`,
|
||||
the analytic Hessian of `snoise(float3)` on the same lattice, hash and tie order as
|
||||
`snoise_grad` (a symmetric `float3x3`; GLSL `mat3`), and `integral_clamp_linear(a,
|
||||
b, lo, hi, t0, t1)`, the closed-form integral over `[t0, t1]` of `clamp(a + b t,
|
||||
lo, hi)`. Three measured bounds describe `snoise`: `SNOISE_LIPSCHITZ` (gradient
|
||||
length, `NoiseBoundTests.MeasureLipschitz`), `SNOISE_HESSIAN` (Hessian spectral
|
||||
norm, `MeasureHessian`) and `SNOISE_THIRD` (third-derivative operator norm,
|
||||
`MeasureThird`); each is 1.10 times a seeded maximum refined by ascent, and a
|
||||
fast test pins it. They are empirical with a margin, not proofs.
|
||||
- `Voronoi.SampleTones`, a C# batch surface that calls the Rust
|
||||
`cultmath-core` native kernel when `cultmath_core` is available and falls back
|
||||
to the managed parity path otherwise.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,35 @@ permutation and scale as `snoise(float3)`.
|
|||
- Upstream: <https://github.com/ashima/webgl-noise>
|
||||
- License: MIT.
|
||||
|
||||
## Cycles node semantics
|
||||
|
||||
`math.cycles_*` and `cultmath_cycles_*` port what Blender's Cycles renderer
|
||||
computes for the Math, Map Range, Clamp, Mix and RGB to BW nodes and for a Color
|
||||
Ramp lookup. Each function's comment names the Cycles file it follows, all from
|
||||
`intern/cycles` at the Blender `v5.2.2` tag: `kernel/svm/math_util.h`,
|
||||
`kernel/svm/map_range.h`, `kernel/svm/clamp.h`, `kernel/svm/mix.h`,
|
||||
`kernel/svm/color_util.h`, `kernel/svm/ramp.h`, `kernel/svm/convert.h`,
|
||||
`util/math_base.h`, `util/math_float3.h` and `util/color.h`. The code was
|
||||
changed: Cycles C++ became C# and HLSL, one function per node operation, with
|
||||
the nodes' socket defaults and clamp options left to the caller, and the modulo
|
||||
is the quotient form every CultMath target shares rather than `fmodf`. Nothing
|
||||
was read from Blender's GPL sources (`source/blender/gpu`, `source/blender/blenlib`).
|
||||
|
||||
- Copyright 2011-2022 Blender Foundation.
|
||||
- Upstream: <https://projects.blender.org/blender/blender> (`intern/cycles`).
|
||||
- License: Apache-2.0 (<https://www.apache.org/licenses/LICENSE-2.0>), which is
|
||||
per-file here: it covers `src/CultMath/math.Cycles.cs`,
|
||||
`shaders/CultMath.Cycles.hlsl` and the Unity copy of that include, and
|
||||
`shaders/CultMath.Cycles.glsl`, which is generated from that include. Each
|
||||
carries the Apache-2.0 header and this attribution. The rest of CultMath, the
|
||||
generated GLSL library included, is MIT or MPL-2.0 as above, so the
|
||||
`GameCult.Math` NuGet package and the `org.gamecult.cultmath` Unity package
|
||||
both declare `MIT AND MPL-2.0 AND Apache-2.0` and ship this file.
|
||||
- The test fixtures under `tests/CultMath.Tests/fixtures/cycles-oracle` are
|
||||
results of running Blender 5.2.2 through `tools/cycles-oracle/oracle.py`
|
||||
(GPL-3.0-or-later, a Blender Python script, shipped neither in the NuGet
|
||||
package nor in the Unity package).
|
||||
|
||||
## Phacelle Noise
|
||||
|
||||
`math.phacelle` and `cultmath_phacelle` follow Rune Skovbo Johansen's Phacelle
|
||||
|
|
|
|||
|
|
@ -764,12 +764,29 @@ radius is small and the axis carries the variation. `af_fbm` compounds octaves
|
|||
as `iv_fbm_ball` does. The float32 evaluation allowance is derived in
|
||||
`af_snoise`'s comment.
|
||||
|
||||
`SNOISE_HESSIAN = 56.050385` is 1.10 times the refined maximum of the spectral
|
||||
`SNOISE_HESSIAN = 55.642143` is 1.10 times the refined maximum of the spectral
|
||||
norm of the Hessian of `snoise(float3)`, measured by
|
||||
`NoiseBoundTests.MeasureHessian` over 1e6 seeded points (central differences of
|
||||
`snoise_grad`) and refined by ascent from the 1e4 largest: sampled 50.1366,
|
||||
refined 50.9549, witness (-245.19885, -53.01323, -91.47363).
|
||||
`NoiseBoundTests.MeasureHessian` over 1e6 seeded points (the analytic
|
||||
`snoise_hessian`, below) and refined by ascent from the 1e4 largest: refined
|
||||
maximum 50.5838. The central differences of `snoise_grad` that an earlier pin
|
||||
used read 50.955 at h = 1e-3, their roundoff.
|
||||
`HessianConstantPinsSampledCurvature` fails on a 1% move.
|
||||
`snoise_hessian(float3)` (`math.Span.cs`) is the analytic Hessian of `snoise`: a
|
||||
symmetric `float3x3` on the same lattice, hash and tie order as `snoise_grad`,
|
||||
differentiating each corner's gradient `-8 m^3 s x + m^4 p` once more. Each
|
||||
off-diagonal entry is computed once and placed twice, so the matrix equals its
|
||||
transpose bit for bit, and a clamped corner (`m = 0`) contributes nothing. Built
|
||||
from nine scalars, the matrix reads as columns in GLSL (`mat3`) and rows in HLSL,
|
||||
which is the same matrix because it is symmetric. `SNOISE_THIRD = 536.9152`
|
||||
(`math.Span.cs`) is the next order: 1.10 times the maximum, over 1e6 seeded points,
|
||||
of the operator norm of the third-derivative tensor of `snoise` (central
|
||||
differences of `snoise_hessian` at h = 1e-3, then an ascent over unit vectors),
|
||||
refined from the 1e4 largest; measured 488.1047 on the tied kernel.
|
||||
`MeasureThird` takes the measurement and `ThirdConstantPinsSampled` pins it.
|
||||
`integral_clamp_linear(a, b, lo, hi, t0, t1)` is the closed-form integral over
|
||||
`[t0, t1]` of `clamp(a + b t, lo, hi)`. All three have HLSL and GLSL lowerings and
|
||||
`glsl-parity.json` fixture families (`snoise_hessian`, `integral_clamp_linear`; the
|
||||
`af_snoise` and `af_fbm` families are regenerated for the re-pin).
|
||||
`SNOISE2_LIPSCHITZ = 8.117002` is the same for the gradient of `snoise(float2)`
|
||||
(`MeasureLipschitz2`: sampled 7.2575, refined 7.3791, witness (-67.12454,
|
||||
-129.53296), `|snoise2|` at most 0.99964), and it backs `iv_snoise_ball(float2,
|
||||
|
|
@ -779,7 +796,7 @@ curvature constant 20% low, so `SNOISE_HESSIAN` is defended by
|
|||
(`af_snoise` at the point where the curvature is realised, along 4,000 directions: a
|
||||
constant of 40 misses there). The margin over the function itself: a scan of the whole
|
||||
289^3 hash period found a realised maximum of 50.58, and bounding each corner's gradient
|
||||
independently over every hash configuration gives 52.22, so 56.05 sits 7% above the
|
||||
independently over every hash configuration gives 52.22, so 55.64 sits 7% above the
|
||||
configuration bound. A change to the kernels must re-run the measurements.
|
||||
|
||||
The slice. `af_frustum_ball(centreSlope, z0, z1, footprintPerDepth,
|
||||
|
|
@ -834,6 +851,39 @@ interval, under the affine bound and under the oracle mask (one logarithmic
|
|||
scale) and the transmittance. Its run: 4.23, 3.60 and 2.02 cells per ray on
|
||||
average, 44 the most, mean transmittance 0.278.
|
||||
|
||||
## Expressions
|
||||
|
||||
`CultMath.Expr` holds a scalar SSA expression graph as a persisted value, a builder
|
||||
for it, and a C# evaluator.
|
||||
|
||||
- **Graph (`ExprGraph`)**: a sealed immutable class. It has no name of its own; the
|
||||
consumer record that holds it names it. Nodes are in topological order, an operand
|
||||
names a lower node, and an edit builds a new graph. A persisted graph carries a
|
||||
format version (1); one naming an unknown op, a newer format or a forward operand is
|
||||
refused whole at decode. `ExprGraph.Create` is the one validator, run by the builder
|
||||
and by decode.
|
||||
- **Node**: its index in the topological order. The builder hash-conses identical
|
||||
(op, operands, constant bits) nodes and drops nodes no output reaches, so one
|
||||
expression always builds the same bytes; any rebuild may renumber nodes.
|
||||
- **Port**: graph inputs vary per point, params are uniform per batch and carry a
|
||||
default, outputs name a node. Names are `[A-Za-z_][A-Za-z0-9_]*`, unique per kind.
|
||||
An op's operands are positional; a multi-result op (`cellular3`: nearest.xyzw,
|
||||
edge.xyzw, id) is read only through `Pick`.
|
||||
- **Op vocabulary**: one table (`ExprOps`), a stable string per op, append-only.
|
||||
Vector arguments are decomposed operands: `snoise2`, `snoise3`, `cellular3`. An op
|
||||
calls its CultMath function; a function's value change is a Breaking release note.
|
||||
- **Evaluation**: `ExprEvaluator.Evaluate` runs the nodes in order through the table,
|
||||
one IEEE single per op, so a result equals the direct composition of the same math
|
||||
calls bit for bit. Params bind by name (`ExprBindings`): a missing name takes its
|
||||
default and an unknown name is refused.
|
||||
- **Stencils** are substitution at build time (`ExprBuilder.Substitute`, Fidget's
|
||||
`remap_xyz`): N substitutions of the position inputs combined by ops give an ordinary
|
||||
flat graph. There is no loop construct.
|
||||
|
||||
The MessagePack shape belongs to `GameCult.Caching.MessagePack` (`CultMathExprResolver`).
|
||||
Interval, affine and gradient evaluation over the graph, bytecode, the HLSL
|
||||
interpreter and pruning are not built.
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep type names HLSL-shaped: `float2`, `float3`, `float4`, `float3x3`, and `math`.
|
||||
|
|
|
|||
|
|
@ -86,7 +86,9 @@ $requiredTemplateFiles = @(
|
|||
"Shaders\CultMath.Interval.hlsl",
|
||||
"Shaders\CultMath.Interval.hlsl.meta",
|
||||
"Shaders\CultMath.Affine.hlsl",
|
||||
"Shaders\CultMath.Affine.hlsl.meta"
|
||||
"Shaders\CultMath.Affine.hlsl.meta",
|
||||
"Shaders\CultMath.Cycles.hlsl",
|
||||
"Shaders\CultMath.Cycles.hlsl.meta"
|
||||
)
|
||||
foreach ($relativePath in $requiredTemplateFiles) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $templateRoot $relativePath) -PathType Leaf)) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@
|
|||
|
||||
// An upper bound on the spectral norm of the Hessian of cultmath_snoise(float3); provenance in
|
||||
// math.Affine.cs (SNOISE_HESSIAN).
|
||||
static const float CULTMATH_SNOISE_HESSIAN = 56.050385;
|
||||
static const float CULTMATH_SNOISE_HESSIAN = 55.642143;
|
||||
|
||||
// An upper bound on the operator norm of the third-derivative tensor of cultmath_snoise(float3), the
|
||||
// next order after CULTMATH_SNOISE_HESSIAN; provenance in math.Span.cs (SNOISE_THIRD).
|
||||
static const float CULTMATH_SNOISE_THIRD = 536.9152;
|
||||
|
||||
float3 cultmath_af_point(float value) { return float3(value, 0.0, 0.0); }
|
||||
float3 cultmath_af_symbol(float x0, float a) { return float3(x0, a, 0.0); }
|
||||
|
|
|
|||
363
packages/cultmath/shaders/CultMath.Cycles.glsl
Normal file
363
packages/cultmath/shaders/CultMath.Cycles.glsl
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
// Generated from shaders/CultMath.Cycles.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. Apache-2.0; requires CultMath.glsl before it.
|
||||
#ifndef CULTMATH_CYCLES_GLSL
|
||||
#define CULTMATH_CYCLES_GLSL
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Cycles node semantics: what Blender's Cycles renderer computes for the Math, Map Range, Clamp, Mix and
|
||||
// RGB to BW nodes and for a Color Ramp lookup, ported function by function from the Cycles kernel
|
||||
// (intern/cycles of the Blender v5.2.2 tag; SPDX-FileCopyrightText: 2011-2022 Blender Foundation, Apache-2.0):
|
||||
// kernel/svm/{math_util,map_range,clamp,mix,color_util,ramp,convert}.h, util/math_base.h, util/color.h and
|
||||
// util/math_float3.h. Each function's comment names its Cycles source. Modified by GameCult: Cycles C++
|
||||
// becomes HLSL, a function per node operation, and the node's socket defaults and clamp options are left to
|
||||
// the caller. This file is Apache-2.0 (section 4: it was changed); CultMath.hlsl, which includes it after its
|
||||
// own definitions, is MIT (see THIRD-PARTY-NOTICES.md). math.Cycles.cs mirrors it bit for bit.
|
||||
//
|
||||
// Operands are finite floats. NaN and infinite operands are outside the oracle's reach and the mirror's.
|
||||
|
||||
// FLT_EPSILON, the least epsilon a Compare node honours.
|
||||
const float CULTMATH_CYCLES_FLT_EPSILON = 1.1920929e-07;
|
||||
|
||||
// The scene-linear luminance coefficients Cycles reads from Blender 5.2.2's default OCIO config
|
||||
// (kernel/film linear_rgb_to_gray). The oracle fixture pins them with a case per primary.
|
||||
const float CULTMATH_CYCLES_RGB_TO_Y_R = 0.212639093;
|
||||
const float CULTMATH_CYCLES_RGB_TO_Y_G = 0.715169132;
|
||||
const float CULTMATH_CYCLES_RGB_TO_Y_B = 0.0721927434;
|
||||
|
||||
// ---- Math node (kernel/svm/math_util.h svm_math; util/math_base.h) ----
|
||||
|
||||
// safe_divide
|
||||
float cultmath_cycles_divide(float a, float b) { return b != 0.0 ? a / b : 0.0; }
|
||||
|
||||
// safe_powf with compatible_powf. A negative base takes an integral exponent's parity; a base below zero
|
||||
// with a fractional exponent is 0.
|
||||
float cultmath_cycles_power(float a, float b)
|
||||
{
|
||||
float whole = b >= 0.0 ? floor(b) : ceil(b);
|
||||
if (a < 0.0 && b != whole)
|
||||
return 0.0;
|
||||
if (b == 0.0)
|
||||
return 1.0;
|
||||
if (a == 0.0)
|
||||
return 0.0;
|
||||
if (a < 0.0)
|
||||
{
|
||||
float magnitude = pow(-a, b);
|
||||
// Odd exactly when the remainder is 1: an infinite exponent leaves NaN here and is even, as powf(a, +-inf) is never negative.
|
||||
return b - 2.0 * floor(0.5 * b) == 1.0 ? -magnitude : magnitude;
|
||||
}
|
||||
return pow(a, b);
|
||||
}
|
||||
|
||||
// safe_logf
|
||||
float cultmath_cycles_logarithm(float a, float b)
|
||||
{
|
||||
if (a <= 0.0 || b <= 0.0)
|
||||
return 0.0;
|
||||
float denominator = log(b);
|
||||
return denominator != 0.0 ? log(a) / denominator : 0.0;
|
||||
}
|
||||
|
||||
// safe_sqrtf
|
||||
float cultmath_cycles_sqrt(float a) { return sqrt(max(a, 0.0)); }
|
||||
|
||||
// inversesqrtf
|
||||
float cultmath_cycles_inverse_sqrt(float a) { return a > 0.0 ? 1.0 / sqrt(a) : 0.0; }
|
||||
|
||||
// safe_modulo is fmodf, the remainder with the dividend's sign, exactly. GLSL has no fmod, and a - b trunc(a / b)
|
||||
// is wrong where the quotient rounds up to an integer or the product rounds (3 mod 0.3 is 0.29999995, not 0).
|
||||
// So this subtracts the largest power-of-two multiple of the divisor not above the remainder until the
|
||||
// remainder is below the divisor: every step is an exact subtraction, as in fmod. The loops are bounded by the
|
||||
// binades a float spans.
|
||||
float cultmath_cycles_modulo(float a, float b)
|
||||
{
|
||||
float remainder = abs(a);
|
||||
float divisor = abs(b);
|
||||
for (int steps = 0; steps < 300 && remainder >= divisor && divisor > 0.0; steps++)
|
||||
{
|
||||
float multiple = divisor;
|
||||
for (int doublings = 0; doublings < 300 && multiple * 2.0 <= remainder; doublings++)
|
||||
multiple = multiple * 2.0;
|
||||
remainder = remainder - multiple;
|
||||
}
|
||||
return b != 0.0 ? (a < 0.0 ? -remainder : remainder) : 0.0;
|
||||
}
|
||||
|
||||
// safe_floored_modulo; a build that contracts it differs by a bound stated at cycles_floored_modulo in math.Cycles.cs and the README
|
||||
float cultmath_cycles_floored_modulo(float a, float b) { return b != 0.0 ? a - floor(a / b) * b : 0.0; }
|
||||
|
||||
// wrapf (note the argument order: value, max, min); contraction bound as for floored_modulo
|
||||
float cultmath_cycles_wrap(float value, float max_value, float min_value)
|
||||
{
|
||||
float range = max_value - min_value;
|
||||
return range != 0.0 ? value - (range * floor((value - min_value) / range)) : min_value;
|
||||
}
|
||||
|
||||
// floorf(safe_divide(a, b)) * b
|
||||
float cultmath_cycles_snap(float a, float b) { return floor(cultmath_cycles_divide(a, b)) * b; }
|
||||
|
||||
// pingpongf
|
||||
float cultmath_cycles_pingpong(float a, float b)
|
||||
{
|
||||
float scaled = (a - b) / (b * 2.0);
|
||||
return b != 0.0 ? abs((scaled - floor(scaled)) * b * 2.0 - b) : 0.0;
|
||||
}
|
||||
|
||||
// smoothminf
|
||||
float cultmath_cycles_smooth_min(float a, float b, float k)
|
||||
{
|
||||
float h = max(k - abs(a - b), 0.0) / k;
|
||||
return k != 0.0 ? min(a, b) - h * h * h * k * 0.16666667 : min(a, b);
|
||||
}
|
||||
|
||||
// -smoothminf(-a, -b, k)
|
||||
float cultmath_cycles_smooth_max(float a, float b, float k) { return -cultmath_cycles_smooth_min(-a, -b, k); }
|
||||
|
||||
// NODE_MATH_COMPARE
|
||||
float cultmath_cycles_compare(float a, float b, float epsilon)
|
||||
{
|
||||
return (a == b || abs(a - b) <= max(epsilon, CULTMATH_CYCLES_FLT_EPSILON)) ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
// NODE_MATH_ROUND
|
||||
float cultmath_cycles_round(float a) { return floor(a + 0.5); }
|
||||
|
||||
// NODE_MATH_TRUNC
|
||||
float cultmath_cycles_trunc(float a) { return a >= 0.0 ? floor(a) : ceil(a); }
|
||||
|
||||
// NODE_MATH_FRACTION
|
||||
float cultmath_cycles_fract(float a) { return a - floor(a); }
|
||||
|
||||
// compatible_signf
|
||||
float cultmath_cycles_sign(float a) { return a == 0.0 ? 0.0 : (a < 0.0 ? -1.0 : 1.0); }
|
||||
|
||||
// safe_asinf
|
||||
float cultmath_cycles_arcsine(float a) { return asin(clamp(a, -1.0, 1.0)); }
|
||||
|
||||
// safe_acosf
|
||||
float cultmath_cycles_arccosine(float a) { return acos(clamp(a, -1.0, 1.0)); }
|
||||
|
||||
// compatible_atan2 (a is y, b is x)
|
||||
float cultmath_cycles_arctan2(float a, float b) { return (b == 0.0 && a == 0.0) ? 0.0 : atan(a, b); }
|
||||
|
||||
// ---- Map Range node (kernel/svm/map_range.h svm_node_map_range) ----
|
||||
// A Map Range node's Clamp option is a Clamp node of type Range on its result (MapRangeNode::expand):
|
||||
// compose cultmath_cycles_clamp_range over these.
|
||||
|
||||
float cultmath_cycles_map_range_linear(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float factor = (value - from_min) / (from_max - from_min);
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
float cultmath_cycles_map_range_stepped(float value, float from_min, float from_max, float to_min, float to_max, float steps)
|
||||
{
|
||||
float factor = (value - from_min) / (from_max - from_min);
|
||||
factor = steps > 0.0 ? floor(factor * (steps + 1.0)) / steps : 0.0;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// The value itself, not a normalized one, goes through smoothstep(from_min, from_max, .), flipped when
|
||||
// from_min > from_max (util/math_base.h smoothstep).
|
||||
float cultmath_cycles_map_range_smoothstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float low = min(from_min, from_max);
|
||||
float high = max(from_min, from_max);
|
||||
float t = (value - low) / (high - low);
|
||||
float eased = value < low ? 0.0 : (value >= high ? 1.0 : (3.0 - 2.0 * t) * (t * t));
|
||||
float factor = from_min > from_max ? 1.0 - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// smootherstep of map_range.h, flipped when from_min > from_max.
|
||||
float cultmath_cycles_map_range_smootherstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float low = min(from_min, from_max);
|
||||
float high = max(from_min, from_max);
|
||||
float x = clamp(cultmath_cycles_divide(value - low, high - low), 0.0, 1.0);
|
||||
float eased = x * x * x * (x * (x * 6.0 - 15.0) + 10.0);
|
||||
float factor = from_min > from_max ? 1.0 - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// ---- Clamp node (kernel/svm/clamp.h svm_node_clamp) ----
|
||||
|
||||
// Type Min Max
|
||||
float cultmath_cycles_clamp_minmax(float value, float min_value, float max_value) { return clamp(value, min_value, max_value); }
|
||||
|
||||
// Type Range: the bounds swap when min_value > max_value.
|
||||
float cultmath_cycles_clamp_range(float value, float min_value, float max_value)
|
||||
{
|
||||
return min_value > max_value ? clamp(value, max_value, min_value) : clamp(value, min_value, max_value);
|
||||
}
|
||||
|
||||
// ---- Mix node (kernel/svm/mix.h, color_util.h) ----
|
||||
// Factor clamping (svm_node_mix_color's saturatef) and result clamping (saturate) are the caller's.
|
||||
|
||||
// svm_node_mix_float
|
||||
float cultmath_cycles_mix_float(float t, float a, float b) { return a * (1.0 - t) + b * t; }
|
||||
|
||||
// svm_node_mix_vector
|
||||
vec3 cultmath_cycles_mix_vector(float t, vec3 a, vec3 b) { return a * (vec3(1.0, 1.0, 1.0) - t) + b * t; }
|
||||
|
||||
vec3 cultmath_cycles_mix_blend(float t, vec3 col1, vec3 col2) { return col1 + t * (col2 - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_add(float t, vec3 col1, vec3 col2) { return col1 + t * ((col1 + col2) - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_mul(float t, vec3 col1, vec3 col2) { return col1 + t * (col1 * col2 - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_screen(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
vec3 one = vec3(1.0, 1.0, 1.0);
|
||||
return one - (vec3(tm, tm, tm) + t * (one - col2)) * (one - col1);
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_overlay(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
return vec3(
|
||||
col1.x < 0.5 ? col1.x * (tm + 2.0 * t * col2.x) : 1.0 - (tm + 2.0 * t * (1.0 - col2.x)) * (1.0 - col1.x),
|
||||
col1.y < 0.5 ? col1.y * (tm + 2.0 * t * col2.y) : 1.0 - (tm + 2.0 * t * (1.0 - col2.y)) * (1.0 - col1.y),
|
||||
col1.z < 0.5 ? col1.z * (tm + 2.0 * t * col2.z) : 1.0 - (tm + 2.0 * t * (1.0 - col2.z)) * (1.0 - col1.z));
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_sub(float t, vec3 col1, vec3 col2) { return col1 + t * ((col1 - col2) - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_div(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
return vec3(
|
||||
col2.x != 0.0 ? tm * col1.x + t * col1.x / col2.x : col1.x,
|
||||
col2.y != 0.0 ? tm * col1.y + t * col1.y / col2.y : col1.y,
|
||||
col2.z != 0.0 ? tm * col1.z + t * col1.z / col2.z : col1.z);
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_diff(float t, vec3 col1, vec3 col2) { return col1 + t * (abs(col1 - col2) - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_exclusion(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
return max(col1 + t * ((col1 + col2 - 2.0 * col1 * col2) - col1), vec3(0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_dark(float t, vec3 col1, vec3 col2) { return col1 + t * (min(col1, col2) - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_light(float t, vec3 col1, vec3 col2) { return col1 + t * (max(col1, col2) - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_dodge(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
vec3 divisor = vec3(1.0, 1.0, 1.0) - t * col2;
|
||||
return vec3(
|
||||
col1.x != 0.0 ? (divisor.x <= 0.0 ? 1.0 : min(col1.x / divisor.x, 1.0)) : col1.x,
|
||||
col1.y != 0.0 ? (divisor.y <= 0.0 ? 1.0 : min(col1.y / divisor.y, 1.0)) : col1.y,
|
||||
col1.z != 0.0 ? (divisor.z <= 0.0 ? 1.0 : min(col1.z / divisor.z, 1.0)) : col1.z);
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_burn(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
vec3 divisor = tm + t * col2;
|
||||
vec3 burnt = vec3(1.0, 1.0, 1.0) - (vec3(1.0, 1.0, 1.0) - col1) / divisor;
|
||||
return vec3(
|
||||
divisor.x <= 0.0 ? 0.0 : clamp(burnt.x, 0.0, 1.0),
|
||||
divisor.y <= 0.0 ? 0.0 : clamp(burnt.y, 0.0, 1.0),
|
||||
divisor.z <= 0.0 ? 0.0 : clamp(burnt.z, 0.0, 1.0));
|
||||
}
|
||||
|
||||
// rgb_to_hsv of util/color.h
|
||||
vec3 cultmath_cycles_rgb_to_hsv(vec3 rgb)
|
||||
{
|
||||
float cmax = max(rgb.x, max(rgb.y, rgb.z));
|
||||
float cmin = min(rgb.x, min(rgb.y, rgb.z));
|
||||
float cdelta = cmax - cmin;
|
||||
float s = cmax != 0.0 ? cdelta / cmax : 0.0;
|
||||
vec3 c = (vec3(cmax, cmax, cmax) - rgb) / cdelta;
|
||||
float h = rgb.x == cmax ? c.z - c.y : (rgb.y == cmax ? 2.0 + c.x - c.z : 4.0 + c.y - c.x);
|
||||
h = h * 0.16666667; // Cycles' kernel is built with reciprocal math: h /= 6.0f computes h * (1 / 6.0f).
|
||||
h = h < 0.0 ? h + 1.0 : h;
|
||||
return vec3(s != 0.0 ? h : 0.0, s, cmax);
|
||||
}
|
||||
|
||||
// hsv_to_rgb of util/color.h
|
||||
vec3 cultmath_cycles_hsv_to_rgb(vec3 hsv)
|
||||
{
|
||||
float h = hsv.x == 1.0 ? 0.0 : hsv.x;
|
||||
h = h * 6.0;
|
||||
float i = floor(h);
|
||||
float f = h - i;
|
||||
float p = hsv.z * (1.0 - hsv.y);
|
||||
float q = hsv.z * (1.0 - (hsv.y * f));
|
||||
float t = hsv.z * (1.0 - (hsv.y * (1.0 - f)));
|
||||
vec3 rgb = i == 0.0 ? vec3(hsv.z, t, p) :
|
||||
(i == 1.0 ? vec3(q, hsv.z, p) :
|
||||
(i == 2.0 ? vec3(p, hsv.z, t) :
|
||||
(i == 3.0 ? vec3(p, q, hsv.z) :
|
||||
(i == 4.0 ? vec3(t, p, hsv.z) : vec3(hsv.z, p, q)))));
|
||||
return hsv.y != 0.0 ? rgb : vec3(hsv.z, hsv.z, hsv.z);
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_hue(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
vec3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
vec3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
vec3 shifted = cultmath_cycles_hsv_to_rgb(vec3(hsv2.x, hsv.y, hsv.z));
|
||||
return hsv2.y != 0.0 ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_sat(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
vec3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
vec3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
return hsv.y != 0.0 ? cultmath_cycles_hsv_to_rgb(vec3(hsv.x, tm * hsv.y + t * hsv2.y, hsv.z)) : col1;
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_val(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
vec3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
vec3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
return cultmath_cycles_hsv_to_rgb(vec3(hsv.x, hsv.y, tm * hsv.z + t * hsv2.z));
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_color(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
vec3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
vec3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
vec3 shifted = cultmath_cycles_hsv_to_rgb(vec3(hsv2.x, hsv2.y, hsv.z));
|
||||
return hsv2.y != 0.0 ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_soft(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
vec3 one = vec3(1.0, 1.0, 1.0);
|
||||
vec3 scr = one - (one - col2) * (one - col1);
|
||||
return tm * col1 + t * ((one - col1) * col2 * col1 + col1 * scr);
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_linear(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
return col1 + t * (2.0 * col2 + vec3(-1.0, -1.0, -1.0));
|
||||
}
|
||||
|
||||
// ---- RGB to BW node (kernel/svm/convert.h NODE_CONVERT_CF) ----
|
||||
|
||||
float cultmath_cycles_rgb_to_bw(vec3 color)
|
||||
{
|
||||
return color.x * CULTMATH_CYCLES_RGB_TO_Y_R + color.y * CULTMATH_CYCLES_RGB_TO_Y_G + color.z * CULTMATH_CYCLES_RGB_TO_Y_B;
|
||||
}
|
||||
|
||||
// ---- Color Ramp lookup (kernel/svm/ramp.h rgb_ramp_lookup, without extrapolation) ----
|
||||
|
||||
// Where a ramp of `size` table entries is read at t: (index, weight), the lower entry and the weight of the
|
||||
// next one. The colour is table[index] when weight is 0 and (1 - weight) table[index] + weight table[index + 1]
|
||||
// otherwise. The ramp table itself is the caller's; Blender's holds 257 entries, the ramp sampled at i / 256.
|
||||
vec2 cultmath_cycles_ramp_index(float t, int size)
|
||||
{
|
||||
float f = (t > 0.0 ? (t < 1.0 ? t : 1.0) : 0.0) * float(size - 1);
|
||||
int index = clamp(int(f), 0, size - 1);
|
||||
float lower = float(index);
|
||||
return vec2(lower, f - lower);
|
||||
}
|
||||
|
||||
#endif
|
||||
363
packages/cultmath/shaders/CultMath.Cycles.hlsl
Normal file
363
packages/cultmath/shaders/CultMath.Cycles.hlsl
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
#ifndef CULTMATH_CYCLES_HLSL
|
||||
#define CULTMATH_CYCLES_HLSL
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Cycles node semantics: what Blender's Cycles renderer computes for the Math, Map Range, Clamp, Mix and
|
||||
// RGB to BW nodes and for a Color Ramp lookup, ported function by function from the Cycles kernel
|
||||
// (intern/cycles of the Blender v5.2.2 tag; SPDX-FileCopyrightText: 2011-2022 Blender Foundation, Apache-2.0):
|
||||
// kernel/svm/{math_util,map_range,clamp,mix,color_util,ramp,convert}.h, util/math_base.h, util/color.h and
|
||||
// util/math_float3.h. Each function's comment names its Cycles source. Modified by GameCult: Cycles C++
|
||||
// becomes HLSL, a function per node operation, and the node's socket defaults and clamp options are left to
|
||||
// the caller. This file is Apache-2.0 (section 4: it was changed); CultMath.hlsl, which includes it after its
|
||||
// own definitions, is MIT (see THIRD-PARTY-NOTICES.md). math.Cycles.cs mirrors it bit for bit.
|
||||
//
|
||||
// Operands are finite floats. NaN and infinite operands are outside the oracle's reach and the mirror's.
|
||||
|
||||
// FLT_EPSILON, the least epsilon a Compare node honours.
|
||||
static const float CULTMATH_CYCLES_FLT_EPSILON = 1.1920929e-07;
|
||||
|
||||
// The scene-linear luminance coefficients Cycles reads from Blender 5.2.2's default OCIO config
|
||||
// (kernel/film linear_rgb_to_gray). The oracle fixture pins them with a case per primary.
|
||||
static const float CULTMATH_CYCLES_RGB_TO_Y_R = 0.212639093;
|
||||
static const float CULTMATH_CYCLES_RGB_TO_Y_G = 0.715169132;
|
||||
static const float CULTMATH_CYCLES_RGB_TO_Y_B = 0.0721927434;
|
||||
|
||||
// ---- Math node (kernel/svm/math_util.h svm_math; util/math_base.h) ----
|
||||
|
||||
// safe_divide
|
||||
float cultmath_cycles_divide(float a, float b) { return b != 0.0 ? a / b : 0.0; }
|
||||
|
||||
// safe_powf with compatible_powf. A negative base takes an integral exponent's parity; a base below zero
|
||||
// with a fractional exponent is 0.
|
||||
float cultmath_cycles_power(float a, float b)
|
||||
{
|
||||
float whole = b >= 0.0 ? floor(b) : ceil(b);
|
||||
if (a < 0.0 && b != whole)
|
||||
return 0.0;
|
||||
if (b == 0.0)
|
||||
return 1.0;
|
||||
if (a == 0.0)
|
||||
return 0.0;
|
||||
if (a < 0.0)
|
||||
{
|
||||
float magnitude = pow(-a, b);
|
||||
// Odd exactly when the remainder is 1: an infinite exponent leaves NaN here and is even, as powf(a, +-inf) is never negative.
|
||||
return b - 2.0 * floor(0.5 * b) == 1.0 ? -magnitude : magnitude;
|
||||
}
|
||||
return pow(a, b);
|
||||
}
|
||||
|
||||
// safe_logf
|
||||
float cultmath_cycles_logarithm(float a, float b)
|
||||
{
|
||||
if (a <= 0.0 || b <= 0.0)
|
||||
return 0.0;
|
||||
float denominator = log(b);
|
||||
return denominator != 0.0 ? log(a) / denominator : 0.0;
|
||||
}
|
||||
|
||||
// safe_sqrtf
|
||||
float cultmath_cycles_sqrt(float a) { return sqrt(max(a, 0.0)); }
|
||||
|
||||
// inversesqrtf
|
||||
float cultmath_cycles_inverse_sqrt(float a) { return a > 0.0 ? 1.0 / sqrt(a) : 0.0; }
|
||||
|
||||
// safe_modulo is fmodf, the remainder with the dividend's sign, exactly. GLSL has no fmod, and a - b trunc(a / b)
|
||||
// is wrong where the quotient rounds up to an integer or the product rounds (3 mod 0.3 is 0.29999995, not 0).
|
||||
// So this subtracts the largest power-of-two multiple of the divisor not above the remainder until the
|
||||
// remainder is below the divisor: every step is an exact subtraction, as in fmod. The loops are bounded by the
|
||||
// binades a float spans.
|
||||
float cultmath_cycles_modulo(float a, float b)
|
||||
{
|
||||
float remainder = abs(a);
|
||||
float divisor = abs(b);
|
||||
for (int steps = 0; steps < 300 && remainder >= divisor && divisor > 0.0; steps++)
|
||||
{
|
||||
float multiple = divisor;
|
||||
for (int doublings = 0; doublings < 300 && multiple * 2.0 <= remainder; doublings++)
|
||||
multiple = multiple * 2.0;
|
||||
remainder = remainder - multiple;
|
||||
}
|
||||
return b != 0.0 ? (a < 0.0 ? -remainder : remainder) : 0.0;
|
||||
}
|
||||
|
||||
// safe_floored_modulo; a build that contracts it differs by a bound stated at cycles_floored_modulo in math.Cycles.cs and the README
|
||||
float cultmath_cycles_floored_modulo(float a, float b) { return b != 0.0 ? a - floor(a / b) * b : 0.0; }
|
||||
|
||||
// wrapf (note the argument order: value, max, min); contraction bound as for floored_modulo
|
||||
float cultmath_cycles_wrap(float value, float max_value, float min_value)
|
||||
{
|
||||
float range = max_value - min_value;
|
||||
return range != 0.0 ? value - (range * floor((value - min_value) / range)) : min_value;
|
||||
}
|
||||
|
||||
// floorf(safe_divide(a, b)) * b
|
||||
float cultmath_cycles_snap(float a, float b) { return floor(cultmath_cycles_divide(a, b)) * b; }
|
||||
|
||||
// pingpongf
|
||||
float cultmath_cycles_pingpong(float a, float b)
|
||||
{
|
||||
float scaled = (a - b) / (b * 2.0);
|
||||
return b != 0.0 ? abs((scaled - floor(scaled)) * b * 2.0 - b) : 0.0;
|
||||
}
|
||||
|
||||
// smoothminf
|
||||
float cultmath_cycles_smooth_min(float a, float b, float k)
|
||||
{
|
||||
float h = max(k - abs(a - b), 0.0) / k;
|
||||
return k != 0.0 ? min(a, b) - h * h * h * k * 0.16666667 : min(a, b);
|
||||
}
|
||||
|
||||
// -smoothminf(-a, -b, k)
|
||||
float cultmath_cycles_smooth_max(float a, float b, float k) { return -cultmath_cycles_smooth_min(-a, -b, k); }
|
||||
|
||||
// NODE_MATH_COMPARE
|
||||
float cultmath_cycles_compare(float a, float b, float epsilon)
|
||||
{
|
||||
return (a == b || abs(a - b) <= max(epsilon, CULTMATH_CYCLES_FLT_EPSILON)) ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
// NODE_MATH_ROUND
|
||||
float cultmath_cycles_round(float a) { return floor(a + 0.5); }
|
||||
|
||||
// NODE_MATH_TRUNC
|
||||
float cultmath_cycles_trunc(float a) { return a >= 0.0 ? floor(a) : ceil(a); }
|
||||
|
||||
// NODE_MATH_FRACTION
|
||||
float cultmath_cycles_fract(float a) { return a - floor(a); }
|
||||
|
||||
// compatible_signf
|
||||
float cultmath_cycles_sign(float a) { return a == 0.0 ? 0.0 : (a < 0.0 ? -1.0 : 1.0); }
|
||||
|
||||
// safe_asinf
|
||||
float cultmath_cycles_arcsine(float a) { return asin(clamp(a, -1.0, 1.0)); }
|
||||
|
||||
// safe_acosf
|
||||
float cultmath_cycles_arccosine(float a) { return acos(clamp(a, -1.0, 1.0)); }
|
||||
|
||||
// compatible_atan2 (a is y, b is x)
|
||||
float cultmath_cycles_arctan2(float a, float b) { return (b == 0.0 && a == 0.0) ? 0.0 : atan2(a, b); }
|
||||
|
||||
// ---- Map Range node (kernel/svm/map_range.h svm_node_map_range) ----
|
||||
// A Map Range node's Clamp option is a Clamp node of type Range on its result (MapRangeNode::expand):
|
||||
// compose cultmath_cycles_clamp_range over these.
|
||||
|
||||
float cultmath_cycles_map_range_linear(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float factor = (value - from_min) / (from_max - from_min);
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
float cultmath_cycles_map_range_stepped(float value, float from_min, float from_max, float to_min, float to_max, float steps)
|
||||
{
|
||||
float factor = (value - from_min) / (from_max - from_min);
|
||||
factor = steps > 0.0 ? floor(factor * (steps + 1.0)) / steps : 0.0;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// The value itself, not a normalized one, goes through smoothstep(from_min, from_max, .), flipped when
|
||||
// from_min > from_max (util/math_base.h smoothstep).
|
||||
float cultmath_cycles_map_range_smoothstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float low = min(from_min, from_max);
|
||||
float high = max(from_min, from_max);
|
||||
float t = (value - low) / (high - low);
|
||||
float eased = value < low ? 0.0 : (value >= high ? 1.0 : (3.0 - 2.0 * t) * (t * t));
|
||||
float factor = from_min > from_max ? 1.0 - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// smootherstep of map_range.h, flipped when from_min > from_max.
|
||||
float cultmath_cycles_map_range_smootherstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float low = min(from_min, from_max);
|
||||
float high = max(from_min, from_max);
|
||||
float x = clamp(cultmath_cycles_divide(value - low, high - low), 0.0, 1.0);
|
||||
float eased = x * x * x * (x * (x * 6.0 - 15.0) + 10.0);
|
||||
float factor = from_min > from_max ? 1.0 - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// ---- Clamp node (kernel/svm/clamp.h svm_node_clamp) ----
|
||||
|
||||
// Type Min Max
|
||||
float cultmath_cycles_clamp_minmax(float value, float min_value, float max_value) { return clamp(value, min_value, max_value); }
|
||||
|
||||
// Type Range: the bounds swap when min_value > max_value.
|
||||
float cultmath_cycles_clamp_range(float value, float min_value, float max_value)
|
||||
{
|
||||
return min_value > max_value ? clamp(value, max_value, min_value) : clamp(value, min_value, max_value);
|
||||
}
|
||||
|
||||
// ---- Mix node (kernel/svm/mix.h, color_util.h) ----
|
||||
// Factor clamping (svm_node_mix_color's saturatef) and result clamping (saturate) are the caller's.
|
||||
|
||||
// svm_node_mix_float
|
||||
float cultmath_cycles_mix_float(float t, float a, float b) { return a * (1.0 - t) + b * t; }
|
||||
|
||||
// svm_node_mix_vector
|
||||
float3 cultmath_cycles_mix_vector(float t, float3 a, float3 b) { return a * (float3(1.0, 1.0, 1.0) - t) + b * t; }
|
||||
|
||||
float3 cultmath_cycles_mix_blend(float t, float3 col1, float3 col2) { return col1 + t * (col2 - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_add(float t, float3 col1, float3 col2) { return col1 + t * ((col1 + col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_mul(float t, float3 col1, float3 col2) { return col1 + t * (col1 * col2 - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_screen(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 one = float3(1.0, 1.0, 1.0);
|
||||
return one - (float3(tm, tm, tm) + t * (one - col2)) * (one - col1);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_overlay(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
return float3(
|
||||
col1.x < 0.5 ? col1.x * (tm + 2.0 * t * col2.x) : 1.0 - (tm + 2.0 * t * (1.0 - col2.x)) * (1.0 - col1.x),
|
||||
col1.y < 0.5 ? col1.y * (tm + 2.0 * t * col2.y) : 1.0 - (tm + 2.0 * t * (1.0 - col2.y)) * (1.0 - col1.y),
|
||||
col1.z < 0.5 ? col1.z * (tm + 2.0 * t * col2.z) : 1.0 - (tm + 2.0 * t * (1.0 - col2.z)) * (1.0 - col1.z));
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_sub(float t, float3 col1, float3 col2) { return col1 + t * ((col1 - col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_div(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
return float3(
|
||||
col2.x != 0.0 ? tm * col1.x + t * col1.x / col2.x : col1.x,
|
||||
col2.y != 0.0 ? tm * col1.y + t * col1.y / col2.y : col1.y,
|
||||
col2.z != 0.0 ? tm * col1.z + t * col1.z / col2.z : col1.z);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_diff(float t, float3 col1, float3 col2) { return col1 + t * (abs(col1 - col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_exclusion(float t, float3 col1, float3 col2)
|
||||
{
|
||||
return max(col1 + t * ((col1 + col2 - 2.0 * col1 * col2) - col1), float3(0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_dark(float t, float3 col1, float3 col2) { return col1 + t * (min(col1, col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_light(float t, float3 col1, float3 col2) { return col1 + t * (max(col1, col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_dodge(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float3 divisor = float3(1.0, 1.0, 1.0) - t * col2;
|
||||
return float3(
|
||||
col1.x != 0.0 ? (divisor.x <= 0.0 ? 1.0 : min(col1.x / divisor.x, 1.0)) : col1.x,
|
||||
col1.y != 0.0 ? (divisor.y <= 0.0 ? 1.0 : min(col1.y / divisor.y, 1.0)) : col1.y,
|
||||
col1.z != 0.0 ? (divisor.z <= 0.0 ? 1.0 : min(col1.z / divisor.z, 1.0)) : col1.z);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_burn(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 divisor = tm + t * col2;
|
||||
float3 burnt = float3(1.0, 1.0, 1.0) - (float3(1.0, 1.0, 1.0) - col1) / divisor;
|
||||
return float3(
|
||||
divisor.x <= 0.0 ? 0.0 : clamp(burnt.x, 0.0, 1.0),
|
||||
divisor.y <= 0.0 ? 0.0 : clamp(burnt.y, 0.0, 1.0),
|
||||
divisor.z <= 0.0 ? 0.0 : clamp(burnt.z, 0.0, 1.0));
|
||||
}
|
||||
|
||||
// rgb_to_hsv of util/color.h
|
||||
float3 cultmath_cycles_rgb_to_hsv(float3 rgb)
|
||||
{
|
||||
float cmax = max(rgb.x, max(rgb.y, rgb.z));
|
||||
float cmin = min(rgb.x, min(rgb.y, rgb.z));
|
||||
float cdelta = cmax - cmin;
|
||||
float s = cmax != 0.0 ? cdelta / cmax : 0.0;
|
||||
float3 c = (float3(cmax, cmax, cmax) - rgb) / cdelta;
|
||||
float h = rgb.x == cmax ? c.z - c.y : (rgb.y == cmax ? 2.0 + c.x - c.z : 4.0 + c.y - c.x);
|
||||
h = h * 0.16666667; // Cycles' kernel is built with reciprocal math: h /= 6.0f computes h * (1 / 6.0f).
|
||||
h = h < 0.0 ? h + 1.0 : h;
|
||||
return float3(s != 0.0 ? h : 0.0, s, cmax);
|
||||
}
|
||||
|
||||
// hsv_to_rgb of util/color.h
|
||||
float3 cultmath_cycles_hsv_to_rgb(float3 hsv)
|
||||
{
|
||||
float h = hsv.x == 1.0 ? 0.0 : hsv.x;
|
||||
h = h * 6.0;
|
||||
float i = floor(h);
|
||||
float f = h - i;
|
||||
float p = hsv.z * (1.0 - hsv.y);
|
||||
float q = hsv.z * (1.0 - (hsv.y * f));
|
||||
float t = hsv.z * (1.0 - (hsv.y * (1.0 - f)));
|
||||
float3 rgb = i == 0.0 ? float3(hsv.z, t, p) :
|
||||
(i == 1.0 ? float3(q, hsv.z, p) :
|
||||
(i == 2.0 ? float3(p, hsv.z, t) :
|
||||
(i == 3.0 ? float3(p, q, hsv.z) :
|
||||
(i == 4.0 ? float3(t, p, hsv.z) : float3(hsv.z, p, q)))));
|
||||
return hsv.y != 0.0 ? rgb : float3(hsv.z, hsv.z, hsv.z);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_hue(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 shifted = cultmath_cycles_hsv_to_rgb(float3(hsv2.x, hsv.y, hsv.z));
|
||||
return hsv2.y != 0.0 ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_sat(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
return hsv.y != 0.0 ? cultmath_cycles_hsv_to_rgb(float3(hsv.x, tm * hsv.y + t * hsv2.y, hsv.z)) : col1;
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_val(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
return cultmath_cycles_hsv_to_rgb(float3(hsv.x, hsv.y, tm * hsv.z + t * hsv2.z));
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_color(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 shifted = cultmath_cycles_hsv_to_rgb(float3(hsv2.x, hsv2.y, hsv.z));
|
||||
return hsv2.y != 0.0 ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_soft(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 one = float3(1.0, 1.0, 1.0);
|
||||
float3 scr = one - (one - col2) * (one - col1);
|
||||
return tm * col1 + t * ((one - col1) * col2 * col1 + col1 * scr);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_linear(float t, float3 col1, float3 col2)
|
||||
{
|
||||
return col1 + t * (2.0 * col2 + float3(-1.0, -1.0, -1.0));
|
||||
}
|
||||
|
||||
// ---- RGB to BW node (kernel/svm/convert.h NODE_CONVERT_CF) ----
|
||||
|
||||
float cultmath_cycles_rgb_to_bw(float3 color)
|
||||
{
|
||||
return color.x * CULTMATH_CYCLES_RGB_TO_Y_R + color.y * CULTMATH_CYCLES_RGB_TO_Y_G + color.z * CULTMATH_CYCLES_RGB_TO_Y_B;
|
||||
}
|
||||
|
||||
// ---- Color Ramp lookup (kernel/svm/ramp.h rgb_ramp_lookup, without extrapolation) ----
|
||||
|
||||
// Where a ramp of `size` table entries is read at t: (index, weight), the lower entry and the weight of the
|
||||
// next one. The colour is table[index] when weight is 0 and (1 - weight) table[index] + weight table[index + 1]
|
||||
// otherwise. The ramp table itself is the caller's; Blender's holds 257 entries, the ramp sampled at i / 256.
|
||||
float2 cultmath_cycles_ramp_index(float t, int size)
|
||||
{
|
||||
float f = (t > 0.0 ? (t < 1.0 ? t : 1.0) : 0.0) * (float)(size - 1);
|
||||
int index = clamp((int)f, 0, size - 1);
|
||||
float lower = (float)index;
|
||||
return float2(lower, f - lower);
|
||||
}
|
||||
|
||||
#endif
|
||||
7
packages/cultmath/shaders/CultMath.Cycles.hlsl.meta
Normal file
7
packages/cultmath/shaders/CultMath.Cycles.hlsl.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e7f15f6042b54d728ada5e82dee741b4
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
// Generated from shaders/CultMath.hlsl and CultMath.Interval.hlsl and CultMath.Affine.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MIT. cultmath_phacelle is in CultMath.Phacelle.glsl, to be concatenated after this file by consumers that call it.
|
||||
// Generated from shaders/CultMath.hlsl and CultMath.Interval.hlsl and CultMath.Affine.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MIT. cultmath_phacelle is in CultMath.Phacelle.glsl, to be concatenated after this file by consumers that call it. cultmath_cycles_divide, cultmath_cycles_power, cultmath_cycles_logarithm, cultmath_cycles_sqrt, cultmath_cycles_inverse_sqrt, cultmath_cycles_modulo, cultmath_cycles_floored_modulo, cultmath_cycles_wrap, cultmath_cycles_snap, cultmath_cycles_pingpong, cultmath_cycles_smooth_min, cultmath_cycles_smooth_max, cultmath_cycles_compare, cultmath_cycles_round, cultmath_cycles_trunc, cultmath_cycles_fract, cultmath_cycles_sign, cultmath_cycles_arcsine, cultmath_cycles_arccosine, cultmath_cycles_arctan2, cultmath_cycles_map_range_linear, cultmath_cycles_map_range_stepped, cultmath_cycles_map_range_smoothstep, cultmath_cycles_map_range_smootherstep, cultmath_cycles_clamp_minmax, cultmath_cycles_clamp_range, cultmath_cycles_mix_float, cultmath_cycles_mix_vector, cultmath_cycles_mix_blend, cultmath_cycles_mix_add, cultmath_cycles_mix_mul, cultmath_cycles_mix_screen, cultmath_cycles_mix_overlay, cultmath_cycles_mix_sub, cultmath_cycles_mix_div, cultmath_cycles_mix_diff, cultmath_cycles_mix_exclusion, cultmath_cycles_mix_dark, cultmath_cycles_mix_light, cultmath_cycles_mix_dodge, cultmath_cycles_mix_burn, cultmath_cycles_rgb_to_hsv, cultmath_cycles_hsv_to_rgb, cultmath_cycles_mix_hue, cultmath_cycles_mix_sat, cultmath_cycles_mix_val, cultmath_cycles_mix_color, cultmath_cycles_mix_soft, cultmath_cycles_mix_linear, cultmath_cycles_rgb_to_bw, cultmath_cycles_ramp_index is in CultMath.Cycles.glsl, to be concatenated after this file by consumers that call it.
|
||||
#ifndef CULTMATH_GLSL
|
||||
#define CULTMATH_GLSL
|
||||
// CultMath HLSL mirror.
|
||||
|
|
@ -228,6 +228,97 @@ vec4 cultmath_snoise_grad(vec3 value)
|
|||
return vec4(grad, value2);
|
||||
}
|
||||
|
||||
// Analytic Hessian of cultmath_snoise(vec3); see the C# math.snoise_hessian comment for the
|
||||
// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise_grad's own. The matrix is
|
||||
// built from nine scalars, which read as rows in HLSL and as columns in GLSL; it is symmetric, so the
|
||||
// two agree.
|
||||
mat3 cultmath_snoise_hessian(vec3 value)
|
||||
{
|
||||
vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0);
|
||||
vec3 i = floor(value + dot(value, c.yyy));
|
||||
vec3 x0 = value - i + dot(i, c.xxx);
|
||||
vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x));
|
||||
vec3 l = 1.0 - g;
|
||||
vec3 i1 = min(g.xyz, l.zxy);
|
||||
vec3 i2 = max(g.xyz, l.zxy);
|
||||
vec3 x1 = x0 - i1 + c.xxx;
|
||||
vec3 x2 = x0 - i2 + c.yyy;
|
||||
vec3 x3 = x0 - 0.5;
|
||||
|
||||
i = cultmath_snoise_mod289(i);
|
||||
vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute(
|
||||
i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
|
||||
|
||||
const float n_ = 0.142857142857;
|
||||
vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_);
|
||||
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
|
||||
vec4 x_ = floor(j * ns.z);
|
||||
vec4 y_ = floor(j - 7.0 * x_);
|
||||
vec4 x = x_ * ns.x + ns.yyyy;
|
||||
vec4 y = y_ * ns.x + ns.yyyy;
|
||||
vec4 h = 1.0 - abs(x) - abs(y);
|
||||
|
||||
vec4 b0 = vec4(x.xy, y.xy);
|
||||
vec4 b1 = vec4(x.zw, y.zw);
|
||||
vec4 s0 = floor(b0) * 2.0 + 1.0;
|
||||
vec4 s1 = floor(b1) * 2.0 + 1.0;
|
||||
vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0));
|
||||
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||
|
||||
vec3 p0 = vec3(a0.xy, h.x);
|
||||
vec3 p1 = vec3(a0.zw, h.y);
|
||||
vec3 p2 = vec3(a1.xy, h.z);
|
||||
vec3 p3 = vec3(a1.zw, h.w);
|
||||
vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3));
|
||||
p0 *= norm.x;
|
||||
p1 *= norm.y;
|
||||
p2 *= norm.z;
|
||||
p3 *= norm.w;
|
||||
|
||||
vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
|
||||
vec4 m2 = m0 * m0;
|
||||
vec4 m3 = m2 * m0;
|
||||
vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3));
|
||||
vec4 wide = 48.0 * m2 * px;
|
||||
vec4 narrow = 8.0 * m3;
|
||||
|
||||
vec4 cxx = vec4(x0.x, x1.x, x2.x, x3.x);
|
||||
vec4 cxy = vec4(x0.y, x1.y, x2.y, x3.y);
|
||||
vec4 cxz = vec4(x0.z, x1.z, x2.z, x3.z);
|
||||
vec4 cpx = vec4(p0.x, p1.x, p2.x, p3.x);
|
||||
vec4 cpy = vec4(p0.y, p1.y, p2.y, p3.y);
|
||||
vec4 cpz = vec4(p0.z, p1.z, p2.z, p3.z);
|
||||
|
||||
float hxx = 105.0 * (dot(wide * cxx, cxx) - dot(narrow, 2.0 * cpx * cxx + px));
|
||||
float hyy = 105.0 * (dot(wide * cxy, cxy) - dot(narrow, 2.0 * cpy * cxy + px));
|
||||
float hzz = 105.0 * (dot(wide * cxz, cxz) - dot(narrow, 2.0 * cpz * cxz + px));
|
||||
float hxy = 105.0 * (dot(wide * cxx, cxy) - dot(narrow, cpx * cxy + cpy * cxx));
|
||||
float hxz = 105.0 * (dot(wide * cxx, cxz) - dot(narrow, cpx * cxz + cpz * cxx));
|
||||
float hyz = 105.0 * (dot(wide * cxy, cxz) - dot(narrow, cpy * cxz + cpz * cxy));
|
||||
return mat3(hxx, hxy, hxz, hxy, hyy, hyz, hxz, hyz, hzz);
|
||||
}
|
||||
|
||||
// The integral over [t0, t1] of clamp(a + b t, lo, hi); see the C# math.integral_clamp_linear comment.
|
||||
float cultmath_integral_clamp_linear(float a, float b, float lo, float hi, float t0, float t1)
|
||||
{
|
||||
if (b == 0.0)
|
||||
return clamp(a, lo, hi) * (t1 - t0);
|
||||
|
||||
lo = min(lo, hi);
|
||||
float direction = t1 < t0 ? -1.0 : 1.0;
|
||||
float start = min(t0, t1);
|
||||
float stop = max(t0, t1);
|
||||
float atLo = (lo - a) / b;
|
||||
float atHi = (hi - a) / b;
|
||||
float enter = clamp(min(atLo, atHi), start, stop);
|
||||
float leave = clamp(max(atLo, atHi), start, stop);
|
||||
float before = b > 0.0 ? lo : hi;
|
||||
float after = b > 0.0 ? hi : lo;
|
||||
float total = before * (enter - start) + (a + b * (0.5 * (enter + leave))) * (leave - enter) + after * (stop - leave);
|
||||
return direction * total;
|
||||
}
|
||||
|
||||
// Octave sum of cultmath_snoise_grad (invariant 8); see the C# math.fbm_grad comment for the
|
||||
// per-octave frequency/amplitude derivation and why octaves is clamped.
|
||||
vec4 cultmath_fbm_grad(vec3 p, int octaves, float lacunarity, float gain)
|
||||
|
|
@ -653,7 +744,11 @@ vec4 cultmath_iv_frustum_ball(vec2 centreSlope, float z0, float z1, float footpr
|
|||
|
||||
// An upper bound on the spectral norm of the Hessian of cultmath_snoise(vec3); provenance in
|
||||
// math.Affine.cs (SNOISE_HESSIAN).
|
||||
const float CULTMATH_SNOISE_HESSIAN = 56.050385;
|
||||
const float CULTMATH_SNOISE_HESSIAN = 55.642143;
|
||||
|
||||
// An upper bound on the operator norm of the third-derivative tensor of cultmath_snoise(vec3), the
|
||||
// next order after CULTMATH_SNOISE_HESSIAN; provenance in math.Span.cs (SNOISE_THIRD).
|
||||
const float CULTMATH_SNOISE_THIRD = 536.9152;
|
||||
|
||||
vec3 cultmath_af_point(float value) { return vec3(value, 0.0, 0.0); }
|
||||
vec3 cultmath_af_symbol(float x0, float a) { return vec3(x0, a, 0.0); }
|
||||
|
|
|
|||
|
|
@ -228,6 +228,97 @@ float4 cultmath_snoise_grad(float3 value)
|
|||
return float4(grad, value2);
|
||||
}
|
||||
|
||||
// Analytic Hessian of cultmath_snoise(float3); see the C# math.snoise_hessian comment for the
|
||||
// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise_grad's own. The matrix is
|
||||
// built from nine scalars, which read as rows in HLSL and as columns in GLSL; it is symmetric, so the
|
||||
// two agree.
|
||||
float3x3 cultmath_snoise_hessian(float3 value)
|
||||
{
|
||||
float2 c = float2(1.0 / 6.0, 1.0 / 3.0);
|
||||
float3 i = floor(value + dot(value, c.yyy));
|
||||
float3 x0 = value - i + dot(i, c.xxx);
|
||||
float3 g = float3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x));
|
||||
float3 l = 1.0 - g;
|
||||
float3 i1 = min(g.xyz, l.zxy);
|
||||
float3 i2 = max(g.xyz, l.zxy);
|
||||
float3 x1 = x0 - i1 + c.xxx;
|
||||
float3 x2 = x0 - i2 + c.yyy;
|
||||
float3 x3 = x0 - 0.5;
|
||||
|
||||
i = cultmath_snoise_mod289(i);
|
||||
float4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute(
|
||||
i.z + float4(0.0, i1.z, i2.z, 1.0)) + i.y + float4(0.0, i1.y, i2.y, 1.0)) + i.x + float4(0.0, i1.x, i2.x, 1.0));
|
||||
|
||||
const float n_ = 0.142857142857;
|
||||
float3 ns = float3(2.0 * n_, 0.5 * n_ - 1.0, n_);
|
||||
float4 j = p - 49.0 * floor(p * ns.z * ns.z);
|
||||
float4 x_ = floor(j * ns.z);
|
||||
float4 y_ = floor(j - 7.0 * x_);
|
||||
float4 x = x_ * ns.x + ns.yyyy;
|
||||
float4 y = y_ * ns.x + ns.yyyy;
|
||||
float4 h = 1.0 - abs(x) - abs(y);
|
||||
|
||||
float4 b0 = float4(x.xy, y.xy);
|
||||
float4 b1 = float4(x.zw, y.zw);
|
||||
float4 s0 = floor(b0) * 2.0 + 1.0;
|
||||
float4 s1 = floor(b1) * 2.0 + 1.0;
|
||||
float4 sh = -step(h, float4(0.0, 0.0, 0.0, 0.0));
|
||||
float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||
float4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||
|
||||
float3 p0 = float3(a0.xy, h.x);
|
||||
float3 p1 = float3(a0.zw, h.y);
|
||||
float3 p2 = float3(a1.xy, h.z);
|
||||
float3 p3 = float3(a1.zw, h.w);
|
||||
float4 norm = 1.79284291400159 - 0.85373472095314 * float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3));
|
||||
p0 *= norm.x;
|
||||
p1 *= norm.y;
|
||||
p2 *= norm.z;
|
||||
p3 *= norm.w;
|
||||
|
||||
float4 m0 = max(0.5 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
|
||||
float4 m2 = m0 * m0;
|
||||
float4 m3 = m2 * m0;
|
||||
float4 px = float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3));
|
||||
float4 wide = 48.0 * m2 * px;
|
||||
float4 narrow = 8.0 * m3;
|
||||
|
||||
float4 cxx = float4(x0.x, x1.x, x2.x, x3.x);
|
||||
float4 cxy = float4(x0.y, x1.y, x2.y, x3.y);
|
||||
float4 cxz = float4(x0.z, x1.z, x2.z, x3.z);
|
||||
float4 cpx = float4(p0.x, p1.x, p2.x, p3.x);
|
||||
float4 cpy = float4(p0.y, p1.y, p2.y, p3.y);
|
||||
float4 cpz = float4(p0.z, p1.z, p2.z, p3.z);
|
||||
|
||||
float hxx = 105.0 * (dot(wide * cxx, cxx) - dot(narrow, 2.0 * cpx * cxx + px));
|
||||
float hyy = 105.0 * (dot(wide * cxy, cxy) - dot(narrow, 2.0 * cpy * cxy + px));
|
||||
float hzz = 105.0 * (dot(wide * cxz, cxz) - dot(narrow, 2.0 * cpz * cxz + px));
|
||||
float hxy = 105.0 * (dot(wide * cxx, cxy) - dot(narrow, cpx * cxy + cpy * cxx));
|
||||
float hxz = 105.0 * (dot(wide * cxx, cxz) - dot(narrow, cpx * cxz + cpz * cxx));
|
||||
float hyz = 105.0 * (dot(wide * cxy, cxz) - dot(narrow, cpy * cxz + cpz * cxy));
|
||||
return float3x3(hxx, hxy, hxz, hxy, hyy, hyz, hxz, hyz, hzz);
|
||||
}
|
||||
|
||||
// The integral over [t0, t1] of clamp(a + b t, lo, hi); see the C# math.integral_clamp_linear comment.
|
||||
float cultmath_integral_clamp_linear(float a, float b, float lo, float hi, float t0, float t1)
|
||||
{
|
||||
if (b == 0.0)
|
||||
return clamp(a, lo, hi) * (t1 - t0);
|
||||
|
||||
lo = min(lo, hi);
|
||||
float direction = t1 < t0 ? -1.0 : 1.0;
|
||||
float start = min(t0, t1);
|
||||
float stop = max(t0, t1);
|
||||
float atLo = (lo - a) / b;
|
||||
float atHi = (hi - a) / b;
|
||||
float enter = clamp(min(atLo, atHi), start, stop);
|
||||
float leave = clamp(max(atLo, atHi), start, stop);
|
||||
float before = b > 0.0 ? lo : hi;
|
||||
float after = b > 0.0 ? hi : lo;
|
||||
float total = before * (enter - start) + (a + b * (0.5 * (enter + leave))) * (leave - enter) + after * (stop - leave);
|
||||
return direction * total;
|
||||
}
|
||||
|
||||
// Octave sum of cultmath_snoise_grad (invariant 8); see the C# math.fbm_grad comment for the
|
||||
// per-octave frequency/amplitude derivation and why octaves is clamped.
|
||||
float4 cultmath_fbm_grad(float3 p, int octaves, float lacunarity, float gain)
|
||||
|
|
@ -509,6 +600,7 @@ CultCellular cultmath_cellular(float3 p)
|
|||
|
||||
#include "CultMath.Phacelle.hlsl"
|
||||
#include "CultMath.Interval.hlsl"
|
||||
#include "CultMath.Cycles.hlsl"
|
||||
#include "CultMath.Affine.hlsl"
|
||||
|
||||
float cultmath_value_noise(float2 position)
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
<RootNamespace>CultMath</RootNamespace>
|
||||
<AssemblyName>CultMath</AssemblyName>
|
||||
<PackageId>GameCult.Math</PackageId>
|
||||
<!-- MIT overall; math.Phacelle.cs and CultPhasor.cs (and the two Phacelle HLSL files) are MPL-2.0. -->
|
||||
<PackageLicenseExpression>MIT AND MPL-2.0</PackageLicenseExpression>
|
||||
<!-- MIT overall; math.Phacelle.cs and CultPhasor.cs (and the two Phacelle HLSL files) are MPL-2.0; math.Cycles.cs and CultMath.Cycles.hlsl are Apache-2.0. -->
|
||||
<PackageLicenseExpression>MIT AND MPL-2.0 AND Apache-2.0</PackageLicenseExpression>
|
||||
<Version>0.4.1</Version>
|
||||
<Description>Clean-room C# math shaped for HLSL-style renderer code.</Description>
|
||||
<Authors>GameCult</Authors>
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
<None Include="..\..\shaders\CultMath.Phacelle.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.Phacelle.hlsl" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\shaders\CultMath.Interval.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.Interval.hlsl" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\shaders\CultMath.Affine.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.Affine.hlsl" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\shaders\CultMath.Cycles.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.Cycles.hlsl" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\THIRD-PARTY-NOTICES.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
8
packages/cultmath/src/CultMath/Expr.meta
Normal file
8
packages/cultmath/src/CultMath/Expr.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a99b96b837a3454781b426cec6bfe837
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
39
packages/cultmath/src/CultMath/Expr/ExprBindings.cs
Normal file
39
packages/cultmath/src/CultMath/Expr/ExprBindings.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
namespace CultMath.Expr;
|
||||
|
||||
/// <summary>
|
||||
/// The param values of one graph for an evaluation batch: every param at its default unless overridden by name.
|
||||
/// </summary>
|
||||
public sealed class ExprBindings
|
||||
{
|
||||
private ExprBindings(ExprGraph graph, float[] values)
|
||||
{
|
||||
Graph = graph;
|
||||
Values = values;
|
||||
}
|
||||
|
||||
public ExprGraph Graph { get; }
|
||||
|
||||
/// <summary>One value per param, in the graph's param order.</summary>
|
||||
internal float[] Values { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Binds <paramref name="graph"/>'s params: a name in <paramref name="overrides"/> replaces the param's default
|
||||
/// and every other param keeps it. A name the graph has no param for is refused.
|
||||
/// </summary>
|
||||
public static ExprBindings Bind(ExprGraph graph, IReadOnlyDictionary<string, float>? overrides = null)
|
||||
{
|
||||
var values = new float[graph.ParamNames.Count];
|
||||
for (var i = 0; i < values.Length; i++) values[i] = BitConverter.Int32BitsToSingle(graph.ParamDefaultBits[i]);
|
||||
if (overrides is not null)
|
||||
{
|
||||
foreach (var pair in overrides)
|
||||
{
|
||||
var index = graph.ParamIndex(pair.Key);
|
||||
if (index < 0) throw new ExprGraphException("unknown-param");
|
||||
values[index] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return new ExprBindings(graph, values);
|
||||
}
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/Expr/ExprBindings.cs.meta
Normal file
2
packages/cultmath/src/CultMath/Expr/ExprBindings.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c9dea1bb6c854e8f80a5e0f05902fd17
|
||||
184
packages/cultmath/src/CultMath/Expr/ExprBuilder.cs
Normal file
184
packages/cultmath/src/CultMath/Expr/ExprBuilder.cs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
namespace CultMath.Expr;
|
||||
|
||||
/// <summary>A node of an <see cref="ExprBuilder"/> under construction. Only the builder that made it accepts it.</summary>
|
||||
public sealed class ExprValue
|
||||
{
|
||||
internal ExprValue(ExprBuilder owner, int index, int resultCount)
|
||||
{
|
||||
Owner = owner;
|
||||
Index = index;
|
||||
ResultCount = resultCount;
|
||||
}
|
||||
|
||||
internal ExprBuilder Owner { get; }
|
||||
|
||||
internal int Index { get; }
|
||||
|
||||
/// <summary>More than one for a multi-result op, whose results are read only through <see cref="ExprBuilder.Pick"/>.</summary>
|
||||
public int ResultCount { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="ExprGraph"/>. Identical (op, operands, constant bits) nodes are one node, and
|
||||
/// <see cref="Build"/> drops nodes no output reaches and renumbers densely, so the same call sequence builds
|
||||
/// byte-identical graphs. Every call runs the validator's rules and refuses at the call.
|
||||
/// </summary>
|
||||
public sealed class ExprBuilder
|
||||
{
|
||||
private readonly List<ExprNode> nodes = new();
|
||||
private readonly List<ExprValue> handles = new();
|
||||
private readonly Dictionary<string, int> interned = new();
|
||||
private readonly List<string> inputNames = new();
|
||||
private readonly List<string> paramNames = new();
|
||||
private readonly List<int> paramDefaultBits = new();
|
||||
private readonly List<string> outputNames = new();
|
||||
private readonly List<int> outputNodes = new();
|
||||
|
||||
/// <summary>The graph input of this name, declared on first use.</summary>
|
||||
public ExprValue Input(string name)
|
||||
{
|
||||
var port = inputNames.IndexOf(name);
|
||||
if (port < 0)
|
||||
{
|
||||
ExprGraph.CheckPortName(name);
|
||||
port = inputNames.Count;
|
||||
inputNames.Add(name);
|
||||
}
|
||||
|
||||
return Intern(ExprNode.Input(port));
|
||||
}
|
||||
|
||||
/// <summary>The graph param of this name, declared on first use. Declaring it again with other default bits is refused.</summary>
|
||||
public ExprValue Param(string name, float defaultValue) => ParamBits(name, BitConverter.SingleToInt32Bits(defaultValue));
|
||||
|
||||
/// <summary>A constant; its bits (a signed zero, a NaN payload, a subnormal) are kept.</summary>
|
||||
public ExprValue Const(float value) => ConstBits(BitConverter.SingleToInt32Bits(value));
|
||||
|
||||
public ExprValue Call(string op, params ExprValue[] operands)
|
||||
{
|
||||
var indices = new int[operands.Length];
|
||||
for (var i = 0; i < operands.Length; i++) indices[i] = Own(operands[i]);
|
||||
return Intern(ExprNode.Call(op, indices));
|
||||
}
|
||||
|
||||
/// <summary>Result <paramref name="k"/> of a multi-result node.</summary>
|
||||
public ExprValue Pick(ExprValue node, int k) => Intern(ExprNode.Pick(Own(node), k));
|
||||
|
||||
/// <summary>Names a single-result node as an output.</summary>
|
||||
public void Output(string name, ExprValue value)
|
||||
{
|
||||
ExprGraph.CheckPortName(name);
|
||||
if (outputNames.Contains(name)) throw new ExprGraphException("duplicate-port-name");
|
||||
var node = Own(value);
|
||||
ExprGraph.CheckOutput(node, nodes);
|
||||
outputNames.Add(name);
|
||||
outputNodes.Add(node);
|
||||
}
|
||||
|
||||
/// <summary>The graph of the outputs declared so far: only nodes an output reaches, renumbered densely in build order.</summary>
|
||||
public ExprGraph Build()
|
||||
{
|
||||
var live = new bool[nodes.Count];
|
||||
foreach (var node in outputNodes) live[node] = true;
|
||||
for (var i = nodes.Count - 1; i >= 0; i--)
|
||||
if (live[i])
|
||||
foreach (var operand in nodes[i].Operands) live[operand] = true;
|
||||
|
||||
var renumbered = new int[nodes.Count];
|
||||
var kept = new List<ExprNode>();
|
||||
for (var i = 0; i < nodes.Count; i++)
|
||||
{
|
||||
if (!live[i]) continue;
|
||||
renumbered[i] = kept.Count;
|
||||
kept.Add(nodes[i].WithOperands(nodes[i].Operands.Select(operand => renumbered[operand]).ToArray()));
|
||||
}
|
||||
|
||||
return ExprGraph.Create(
|
||||
ExprGraph.FormatVersion, inputNames, paramNames, paramDefaultBits, outputNames, outputNodes.Select(node => renumbered[node]).ToList(), kept);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds <paramref name="graph"/> inside this builder with the named inputs replaced by the given values
|
||||
/// (Fidget's remap_xyz) and returns each output's value by name. An input not named is this builder's input
|
||||
/// of that name; every param is this builder's param of that name. A stencil is N substitutions combined by
|
||||
/// ops: an ordinary flat graph, with no loop construct.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, ExprValue> Substitute(ExprGraph graph, IReadOnlyDictionary<string, ExprValue> replacements)
|
||||
{
|
||||
foreach (var pair in replacements)
|
||||
{
|
||||
if (!graph.InputNames.Contains(pair.Key)) throw new ExprGraphException("unknown-input");
|
||||
Own(pair.Value);
|
||||
if (pair.Value.ResultCount != 1) throw new ExprGraphException("bare-multi-result");
|
||||
}
|
||||
|
||||
var mapped = new ExprValue[graph.Nodes.Count];
|
||||
for (var i = 0; i < mapped.Length; i++)
|
||||
{
|
||||
var node = graph.Nodes[i];
|
||||
switch (node.Kind)
|
||||
{
|
||||
case ExprNodeKind.Input:
|
||||
var input = graph.InputNames[node.Payload];
|
||||
mapped[i] = replacements.TryGetValue(input, out var replacement) ? replacement : Input(input);
|
||||
break;
|
||||
case ExprNodeKind.Param:
|
||||
mapped[i] = ParamBits(graph.ParamNames[node.Payload], graph.ParamDefaultBits[node.Payload]);
|
||||
break;
|
||||
case ExprNodeKind.Const:
|
||||
mapped[i] = ConstBits(node.Payload);
|
||||
break;
|
||||
case ExprNodeKind.Pick:
|
||||
mapped[i] = Pick(mapped[node.Operands[0]], node.Payload);
|
||||
break;
|
||||
default:
|
||||
mapped[i] = Call(node.WireName, node.Operands.Select(operand => mapped[operand]).ToArray());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var outputs = new Dictionary<string, ExprValue>();
|
||||
for (var o = 0; o < graph.OutputNames.Count; o++) outputs[graph.OutputNames[o]] = mapped[graph.OutputNodes[o]];
|
||||
return outputs;
|
||||
}
|
||||
|
||||
private ExprValue ParamBits(string name, int bits)
|
||||
{
|
||||
var port = paramNames.IndexOf(name);
|
||||
if (port < 0)
|
||||
{
|
||||
ExprGraph.CheckPortName(name);
|
||||
port = paramNames.Count;
|
||||
paramNames.Add(name);
|
||||
paramDefaultBits.Add(bits);
|
||||
}
|
||||
else if (paramDefaultBits[port] != bits)
|
||||
{
|
||||
throw new ExprGraphException("port-conflict");
|
||||
}
|
||||
|
||||
return Intern(ExprNode.Param(port));
|
||||
}
|
||||
|
||||
private ExprValue ConstBits(int bits) => Intern(ExprNode.Const(bits));
|
||||
|
||||
private int Own(ExprValue value)
|
||||
{
|
||||
if (value.Owner != this) throw new ExprGraphException("foreign-operand", nodes.Count);
|
||||
return value.Index;
|
||||
}
|
||||
|
||||
private ExprValue Intern(ExprNode node)
|
||||
{
|
||||
ExprGraph.CheckNode(nodes.Count, node, nodes, inputNames.Count, paramNames.Count);
|
||||
if (!interned.TryGetValue(node.Key, out var index))
|
||||
{
|
||||
index = nodes.Count;
|
||||
nodes.Add(node);
|
||||
handles.Add(new ExprValue(this, index, node.ResultCount));
|
||||
interned[node.Key] = index;
|
||||
}
|
||||
|
||||
return handles[index];
|
||||
}
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/Expr/ExprBuilder.cs.meta
Normal file
2
packages/cultmath/src/CultMath/Expr/ExprBuilder.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7326eeabc2d04a43b3d0a1fe6e45b018
|
||||
57
packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs
Normal file
57
packages/cultmath/src/CultMath/Expr/ExprEvaluator.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
namespace CultMath.Expr;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates an <see cref="ExprGraph"/> per point: nodes in order through <see cref="ExprOps"/>, IEEE single per op,
|
||||
/// so a result equals the direct composition of the same math calls bit for bit. Allocates once per call, never per point.
|
||||
/// </summary>
|
||||
public static class ExprEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Columns are contiguous: input <c>i</c> of point <c>p</c> is <c>inputs[i * n + p]</c>, and output <c>o</c> is
|
||||
/// <c>outputs[o * n + p]</c>, where n = <c>outputs.Length / graph.OutputNames.Count</c>.
|
||||
/// </summary>
|
||||
public static void Evaluate(ExprGraph graph, ExprBindings bindings, ReadOnlySpan<float> inputs, Span<float> outputs)
|
||||
{
|
||||
if (bindings.Graph != graph) throw new ExprGraphException("foreign-bindings");
|
||||
var outputCount = graph.OutputNames.Count;
|
||||
if (outputCount == 0) return;
|
||||
if (outputs.Length % outputCount != 0) throw new ExprGraphException("column-shape");
|
||||
var points = outputs.Length / outputCount;
|
||||
if (inputs.Length != points * graph.InputNames.Count) throw new ExprGraphException("column-shape");
|
||||
|
||||
var nodes = graph.NodeArray;
|
||||
var outputNodes = graph.OutputNodeArray;
|
||||
var start = graph.SlotStart;
|
||||
var slots = new float[graph.SlotCount];
|
||||
Span<float> operands = stackalloc float[ExprOps.MaxArity];
|
||||
for (var point = 0; point < points; point++)
|
||||
{
|
||||
for (var i = 0; i < nodes.Length; i++)
|
||||
{
|
||||
var node = nodes[i];
|
||||
switch (node.Kind)
|
||||
{
|
||||
case ExprNodeKind.Input:
|
||||
slots[start[i]] = inputs[node.Payload * points + point];
|
||||
break;
|
||||
case ExprNodeKind.Param:
|
||||
slots[start[i]] = bindings.Values[node.Payload];
|
||||
break;
|
||||
case ExprNodeKind.Const:
|
||||
slots[start[i]] = BitConverter.Int32BitsToSingle(node.Payload);
|
||||
break;
|
||||
case ExprNodeKind.Pick:
|
||||
slots[start[i]] = slots[start[node.OperandArray[0]] + node.Payload];
|
||||
break;
|
||||
default:
|
||||
var op = node.Op!;
|
||||
for (var k = 0; k < op.Arity; k++) operands[k] = slots[start[node.OperandArray[k]]];
|
||||
op.Evaluate(operands.Slice(0, op.Arity), new Span<float>(slots, start[i], op.ResultCount));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (var o = 0; o < outputCount; o++) outputs[o * points + point] = slots[start[outputNodes[o]]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 38108254b5b4430b8f87094e6905dc59
|
||||
180
packages/cultmath/src/CultMath/Expr/ExprGraph.cs
Normal file
180
packages/cultmath/src/CultMath/Expr/ExprGraph.cs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
namespace CultMath.Expr;
|
||||
|
||||
/// <summary>
|
||||
/// An immutable scalar SSA expression graph: nodes in topological order (an operand names a lower node), named
|
||||
/// inputs that vary per point, named params that are uniform per batch and carry a default, and named outputs.
|
||||
/// A graph is a value; an edit builds a new one. <see cref="Create"/> is the one validator: the builder runs
|
||||
/// it on <see cref="ExprBuilder.Build"/> and a decoder runs it on persisted bytes, so no partial graph is ever returned.
|
||||
/// </summary>
|
||||
public sealed class ExprGraph
|
||||
{
|
||||
/// <summary>The persisted format version this build reads and writes.</summary>
|
||||
public const int FormatVersion = 1;
|
||||
|
||||
private readonly string[] inputNames;
|
||||
private readonly string[] paramNames;
|
||||
private readonly int[] paramDefaultBits;
|
||||
private readonly string[] outputNames;
|
||||
private readonly int[] outputNodes;
|
||||
private readonly ExprNode[] nodes;
|
||||
|
||||
private ExprGraph(string[] inputNames, string[] paramNames, int[] paramDefaultBits, string[] outputNames, int[] outputNodes, ExprNode[] nodes)
|
||||
{
|
||||
this.inputNames = inputNames;
|
||||
this.paramNames = paramNames;
|
||||
this.paramDefaultBits = paramDefaultBits;
|
||||
this.outputNames = outputNames;
|
||||
this.outputNodes = outputNodes;
|
||||
this.nodes = nodes;
|
||||
InputNames = Array.AsReadOnly(inputNames);
|
||||
ParamNames = Array.AsReadOnly(paramNames);
|
||||
ParamDefaultBits = Array.AsReadOnly(paramDefaultBits);
|
||||
OutputNames = Array.AsReadOnly(outputNames);
|
||||
OutputNodes = Array.AsReadOnly(outputNodes);
|
||||
Nodes = Array.AsReadOnly(nodes);
|
||||
SlotStart = new int[nodes.Length];
|
||||
var slots = 0;
|
||||
for (var i = 0; i < nodes.Length; i++)
|
||||
{
|
||||
SlotStart[i] = slots;
|
||||
slots += nodes[i].ResultCount;
|
||||
}
|
||||
|
||||
SlotCount = slots;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> InputNames { get; }
|
||||
|
||||
public IReadOnlyList<string> ParamNames { get; }
|
||||
|
||||
/// <summary>The default of each param as float bits, so a NaN payload or a signed zero survives.</summary>
|
||||
public IReadOnlyList<int> ParamDefaultBits { get; }
|
||||
|
||||
public IReadOnlyList<string> OutputNames { get; }
|
||||
|
||||
/// <summary>The node each output reads.</summary>
|
||||
public IReadOnlyList<int> OutputNodes { get; }
|
||||
|
||||
public IReadOnlyList<ExprNode> Nodes { get; }
|
||||
|
||||
/// <summary>The evaluator's view of the nodes: the private array, never handed out.</summary>
|
||||
internal ExprNode[] NodeArray => nodes;
|
||||
|
||||
/// <summary>The evaluator's view of the output nodes: the private array, never handed out.</summary>
|
||||
internal int[] OutputNodeArray => outputNodes;
|
||||
|
||||
/// <summary>Where each node's results start in an evaluator's value array.</summary>
|
||||
internal int[] SlotStart { get; }
|
||||
|
||||
internal int SlotCount { get; }
|
||||
|
||||
/// <summary>The index of the param of this name, or -1.</summary>
|
||||
internal int ParamIndex(string name) => Array.IndexOf(paramNames, name);
|
||||
|
||||
/// <summary>
|
||||
/// Validates and returns a graph, or throws <see cref="ExprGraphException"/> naming the rule and the node.
|
||||
/// The rules: format version, port counts, port names (syntax, unique per kind), and per node its kind's
|
||||
/// operand count, a known op and its arity, operands below the node and never a bare multi-result node,
|
||||
/// pick range, port index and payload; and per output a node in range that is not a bare multi-result node.
|
||||
/// </summary>
|
||||
public static ExprGraph Create(
|
||||
int version,
|
||||
IReadOnlyList<string> inputNames,
|
||||
IReadOnlyList<string> paramNames,
|
||||
IReadOnlyList<int> paramDefaultBits,
|
||||
IReadOnlyList<string> outputNames,
|
||||
IReadOnlyList<int> outputNodes,
|
||||
IReadOnlyList<ExprNode> nodes)
|
||||
{
|
||||
if (version != FormatVersion) throw new ExprGraphException("version");
|
||||
if (paramNames.Count != paramDefaultBits.Count || outputNames.Count != outputNodes.Count) throw new ExprGraphException("port-count");
|
||||
CheckPortNames(inputNames);
|
||||
CheckPortNames(paramNames);
|
||||
CheckPortNames(outputNames);
|
||||
|
||||
var before = new List<ExprNode>(nodes.Count);
|
||||
for (var i = 0; i < nodes.Count; i++)
|
||||
{
|
||||
CheckNode(i, nodes[i], before, inputNames.Count, paramNames.Count);
|
||||
before.Add(nodes[i]);
|
||||
}
|
||||
|
||||
foreach (var node in outputNodes) CheckOutput(node, before);
|
||||
|
||||
return new ExprGraph(
|
||||
inputNames.ToArray(), paramNames.ToArray(), paramDefaultBits.ToArray(), outputNames.ToArray(), outputNodes.ToArray(), nodes.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>A port name is <c>[A-Za-z_][A-Za-z0-9_]*</c>.</summary>
|
||||
internal static void CheckPortName(string name)
|
||||
{
|
||||
if (name.Length == 0 || !IsHead(name[0])) throw new ExprGraphException("port-name");
|
||||
for (var i = 1; i < name.Length; i++)
|
||||
if (!IsHead(name[i]) && !(name[i] >= '0' && name[i] <= '9')) throw new ExprGraphException("port-name");
|
||||
}
|
||||
|
||||
private static bool IsHead(char c) => c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
|
||||
|
||||
private static void CheckPortNames(IReadOnlyList<string> names)
|
||||
{
|
||||
var seen = new HashSet<string>();
|
||||
foreach (var name in names)
|
||||
{
|
||||
CheckPortName(name);
|
||||
if (!seen.Add(name)) throw new ExprGraphException("duplicate-port-name");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The rules for one node given the nodes before it. The builder runs this per call, the validator per node.</summary>
|
||||
internal static void CheckNode(int index, ExprNode node, IReadOnlyList<ExprNode> before, int inputCount, int paramCount)
|
||||
{
|
||||
var operands = node.Operands;
|
||||
switch (node.Kind)
|
||||
{
|
||||
case ExprNodeKind.Input:
|
||||
CheckLeaf(index, node, inputCount);
|
||||
break;
|
||||
case ExprNodeKind.Param:
|
||||
CheckLeaf(index, node, paramCount);
|
||||
break;
|
||||
case ExprNodeKind.Const:
|
||||
if (operands.Count != 0) throw new ExprGraphException("arity", index);
|
||||
break;
|
||||
case ExprNodeKind.Pick:
|
||||
if (operands.Count != 1) throw new ExprGraphException("arity", index);
|
||||
CheckOperandOrder(index, operands[0]);
|
||||
var results = before[operands[0]].ResultCount;
|
||||
if (results <= 1 || node.Payload < 0 || node.Payload >= results) throw new ExprGraphException("pick-range", index);
|
||||
break;
|
||||
default:
|
||||
if (node.Op is null) throw new ExprGraphException("unknown-op", index);
|
||||
if (operands.Count != node.Op.Arity) throw new ExprGraphException("arity", index);
|
||||
if (node.Payload != 0) throw new ExprGraphException("payload", index);
|
||||
foreach (var operand in operands)
|
||||
{
|
||||
CheckOperandOrder(index, operand);
|
||||
if (before[operand].ResultCount != 1) throw new ExprGraphException("bare-multi-result", index);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckLeaf(int index, ExprNode node, int portCount)
|
||||
{
|
||||
if (node.Operands.Count != 0) throw new ExprGraphException("arity", index);
|
||||
if (node.Payload < 0 || node.Payload >= portCount) throw new ExprGraphException("port-index", index);
|
||||
}
|
||||
|
||||
private static void CheckOperandOrder(int index, int operand)
|
||||
{
|
||||
if (operand < 0 || operand >= index) throw new ExprGraphException("operand-order", index);
|
||||
}
|
||||
|
||||
/// <summary>An output reads a node in range that is single-result.</summary>
|
||||
internal static void CheckOutput(int node, IReadOnlyList<ExprNode> nodes)
|
||||
{
|
||||
if (node < 0 || node >= nodes.Count) throw new ExprGraphException("output-node");
|
||||
if (nodes[node].ResultCount != 1) throw new ExprGraphException("bare-multi-result", node);
|
||||
}
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/Expr/ExprGraph.cs.meta
Normal file
2
packages/cultmath/src/CultMath/Expr/ExprGraph.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 04d707ad2a4e4e0299407ce1d86afea3
|
||||
22
packages/cultmath/src/CultMath/Expr/ExprGraphException.cs
Normal file
22
packages/cultmath/src/CultMath/Expr/ExprGraphException.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
namespace CultMath.Expr;
|
||||
|
||||
/// <summary>
|
||||
/// The one refusal of the expression graph: a build call, a decoded encoding, a binding or an evaluation
|
||||
/// that breaks a rule. <see cref="Rule"/> names the rule and <see cref="NodeIndex"/> the node it broke at
|
||||
/// (-1 when no node is involved). The message names the rule and never echoes a value it was given.
|
||||
/// </summary>
|
||||
public sealed class ExprGraphException : Exception
|
||||
{
|
||||
public ExprGraphException(string rule, int nodeIndex = -1)
|
||||
: base(nodeIndex < 0 ? rule : rule + " at node " + nodeIndex)
|
||||
{
|
||||
Rule = rule;
|
||||
NodeIndex = nodeIndex;
|
||||
}
|
||||
|
||||
/// <summary>The rule broken, a short stable name such as <c>unknown-op</c> or <c>operand-order</c>.</summary>
|
||||
public string Rule { get; }
|
||||
|
||||
/// <summary>The index of the node the rule broke at, or -1.</summary>
|
||||
public int NodeIndex { get; }
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 88e355a7be3f44ada7f1d9464c60428e
|
||||
83
packages/cultmath/src/CultMath/Expr/ExprNode.cs
Normal file
83
packages/cultmath/src/CultMath/Expr/ExprNode.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
namespace CultMath.Expr;
|
||||
|
||||
/// <summary>What a node is: a graph input, a graph param, a constant, a pick of one result of a multi-result node, or a call of an op.</summary>
|
||||
public enum ExprNodeKind
|
||||
{
|
||||
Input,
|
||||
Param,
|
||||
Const,
|
||||
Pick,
|
||||
Call,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One node of an <see cref="ExprGraph"/>: a kind, the operand node indices (always lower than its own),
|
||||
/// and one integer payload: the port index of an input or param, the float bits of a constant, the result
|
||||
/// index of a pick, and zero for a call. Immutable.
|
||||
/// </summary>
|
||||
public sealed class ExprNode
|
||||
{
|
||||
private const string InputName = "input";
|
||||
private const string ParamName = "param";
|
||||
private const string ConstName = "const";
|
||||
private const string PickName = "pick";
|
||||
|
||||
private static readonly int[] NoOperands = Array.Empty<int>();
|
||||
|
||||
private readonly int[] operandArray;
|
||||
|
||||
private ExprNode(ExprNodeKind kind, string wireName, int[] operands, int payload, ExprOp? op)
|
||||
{
|
||||
Kind = kind;
|
||||
WireName = wireName;
|
||||
operandArray = operands;
|
||||
Operands = Array.AsReadOnly(operands);
|
||||
Payload = payload;
|
||||
Op = op;
|
||||
ResultCount = op?.ResultCount ?? 1;
|
||||
}
|
||||
|
||||
public ExprNodeKind Kind { get; }
|
||||
|
||||
/// <summary>The persisted name: an op name for a call, otherwise <c>input</c>, <c>param</c>, <c>const</c> or <c>pick</c>.</summary>
|
||||
public string WireName { get; }
|
||||
|
||||
public IReadOnlyList<int> Operands { get; }
|
||||
|
||||
/// <summary>The evaluator's view of the operands: the private array, never handed out.</summary>
|
||||
internal int[] OperandArray => operandArray;
|
||||
|
||||
public int Payload { get; }
|
||||
|
||||
/// <summary>How many floats the node produces: the op's result count for a call, otherwise one.</summary>
|
||||
public int ResultCount { get; }
|
||||
|
||||
/// <summary>The op of a known call; null for every other node and for a call naming an unknown op.</summary>
|
||||
internal ExprOp? Op { get; }
|
||||
|
||||
public static ExprNode Input(int port) => new(ExprNodeKind.Input, InputName, NoOperands, port, null);
|
||||
|
||||
public static ExprNode Param(int port) => new(ExprNodeKind.Param, ParamName, NoOperands, port, null);
|
||||
|
||||
public static ExprNode Const(int bits) => new(ExprNodeKind.Const, ConstName, NoOperands, bits, null);
|
||||
|
||||
public static ExprNode Pick(int node, int result) => new(ExprNodeKind.Pick, PickName, new[] { node }, result, null);
|
||||
|
||||
public static ExprNode Call(string op, IReadOnlyList<int> operands) =>
|
||||
new(ExprNodeKind.Call, op, operands.ToArray(), 0, ExprOps.Find(op));
|
||||
|
||||
/// <summary>The node a persisted <paramref name="wireName"/> names. A name that is none of the four kinds is a call, which the validator checks against the op table.</summary>
|
||||
public static ExprNode FromWire(string wireName, IReadOnlyList<int> operands, int payload) => wireName switch
|
||||
{
|
||||
InputName => new ExprNode(ExprNodeKind.Input, wireName, operands.ToArray(), payload, null),
|
||||
ParamName => new ExprNode(ExprNodeKind.Param, wireName, operands.ToArray(), payload, null),
|
||||
ConstName => new ExprNode(ExprNodeKind.Const, wireName, operands.ToArray(), payload, null),
|
||||
PickName => new ExprNode(ExprNodeKind.Pick, wireName, operands.ToArray(), payload, null),
|
||||
_ => new ExprNode(ExprNodeKind.Call, wireName, operands.ToArray(), payload, ExprOps.Find(wireName)),
|
||||
};
|
||||
|
||||
internal ExprNode WithOperands(int[] operands) => new(Kind, WireName, operands, Payload, Op);
|
||||
|
||||
/// <summary>Equal keys are the same expression: the builder's hash-consing identity.</summary>
|
||||
internal string Key => WireName + "|" + string.Join(",", operandArray) + "|" + Payload;
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/Expr/ExprNode.cs.meta
Normal file
2
packages/cultmath/src/CultMath/Expr/ExprNode.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: fc3cb4abaa27482aa98a76cd876b8805
|
||||
86
packages/cultmath/src/CultMath/Expr/ExprOps.cs
Normal file
86
packages/cultmath/src/CultMath/Expr/ExprOps.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using static CultMath.math;
|
||||
|
||||
namespace CultMath.Expr;
|
||||
|
||||
/// <summary>One entry of the op table: a stable name, its arity, its result count and its C# implementation.</summary>
|
||||
public sealed class ExprOp
|
||||
{
|
||||
/// <summary>Writes <paramref name="results"/> (<see cref="ResultCount"/> floats) from <paramref name="operands"/>
|
||||
/// (<see cref="Arity"/> floats).</summary>
|
||||
public delegate void Function(ReadOnlySpan<float> operands, Span<float> results);
|
||||
|
||||
private readonly Function function;
|
||||
|
||||
internal ExprOp(string name, int arity, int resultCount, Function function)
|
||||
{
|
||||
Name = name;
|
||||
Arity = arity;
|
||||
ResultCount = resultCount;
|
||||
this.function = function;
|
||||
}
|
||||
|
||||
/// <summary>The persisted name. Append-only: a released name keeps its meaning.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
public int Arity { get; }
|
||||
|
||||
/// <summary>One for a scalar op; more for an op read only through <see cref="ExprBuilder.Pick"/>.</summary>
|
||||
public int ResultCount { get; }
|
||||
|
||||
public void Evaluate(ReadOnlySpan<float> operands, Span<float> results) => function(operands, results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one op table of the expression graph: each op names a CultMath function and calls it, wrapping no type
|
||||
/// and copying no body. The C# evaluator reads it, and so does any later lowering.
|
||||
/// </summary>
|
||||
public static class ExprOps
|
||||
{
|
||||
/// <summary>The largest arity in the table.</summary>
|
||||
internal const int MaxArity = 4;
|
||||
|
||||
private static ExprOp Op(string name, int arity, ExprOp.Function function) => new(name, arity, 1, function);
|
||||
|
||||
private static readonly ExprOp[] Table =
|
||||
{
|
||||
Op("add", 2, (a, r) => r[0] = a[0] + a[1]),
|
||||
Op("sub", 2, (a, r) => r[0] = a[0] - a[1]),
|
||||
Op("mul", 2, (a, r) => r[0] = a[0] * a[1]),
|
||||
Op("div", 2, (a, r) => r[0] = a[0] / a[1]),
|
||||
Op("neg", 1, (a, r) => r[0] = -a[0]),
|
||||
Op("min", 2, (a, r) => r[0] = min(a[0], a[1])),
|
||||
Op("max", 2, (a, r) => r[0] = max(a[0], a[1])),
|
||||
Op("abs", 1, (a, r) => r[0] = abs(a[0])),
|
||||
Op("floor", 1, (a, r) => r[0] = floor(a[0])),
|
||||
Op("frac", 1, (a, r) => r[0] = frac(a[0])),
|
||||
Op("sqrt", 1, (a, r) => r[0] = sqrt(a[0])),
|
||||
Op("exp", 1, (a, r) => r[0] = exp(a[0])),
|
||||
Op("log", 1, (a, r) => r[0] = log(a[0])),
|
||||
Op("pow", 2, (a, r) => r[0] = pow(a[0], a[1])),
|
||||
Op("sin", 1, (a, r) => r[0] = sin(a[0])),
|
||||
Op("cos", 1, (a, r) => r[0] = cos(a[0])),
|
||||
Op("clamp", 3, (a, r) => r[0] = clamp(a[0], a[1], a[2])),
|
||||
Op("saturate", 1, (a, r) => r[0] = saturate(a[0])),
|
||||
Op("lerp", 3, (a, r) => r[0] = lerp(a[0], a[1], a[2])),
|
||||
Op("step", 2, (a, r) => r[0] = step(a[0], a[1])),
|
||||
Op("smoothstep", 3, (a, r) => r[0] = smoothstep(a[0], a[1], a[2])),
|
||||
Op("select", 4, (a, r) => r[0] = select(a[0] > a[1], a[2], a[3])),
|
||||
Op("snoise2", 2, (a, r) => r[0] = snoise(new float2(a[0], a[1]))),
|
||||
Op("snoise3", 3, (a, r) => r[0] = snoise(new float3(a[0], a[1], a[2]))),
|
||||
new ExprOp("cellular3", 3, 9, (a, r) =>
|
||||
{
|
||||
var c = cellular(new float3(a[0], a[1], a[2]));
|
||||
r[0] = c.nearest.x; r[1] = c.nearest.y; r[2] = c.nearest.z; r[3] = c.nearest.w;
|
||||
r[4] = c.edge.x; r[5] = c.edge.y; r[6] = c.edge.z; r[7] = c.edge.w;
|
||||
r[8] = c.id;
|
||||
}),
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, ExprOp> ByName = Table.ToDictionary(op => op.Name);
|
||||
|
||||
/// <summary>Every op, in declaration order.</summary>
|
||||
public static IReadOnlyList<ExprOp> All => Table;
|
||||
|
||||
/// <summary>The op with this persisted name, or null.</summary>
|
||||
public static ExprOp? Find(string name) => ByName.TryGetValue(name, out var op) ? op : null;
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/Expr/ExprOps.cs.meta
Normal file
2
packages/cultmath/src/CultMath/Expr/ExprOps.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f2cb6ebc7a4f43d59009e613a1b75888
|
||||
|
|
@ -21,9 +21,10 @@ public static partial class math
|
|||
// plain function of float values, so it mirrors into the HLSL common subset.
|
||||
|
||||
// An upper bound on the spectral norm of the Hessian of snoise(float3). Provenance:
|
||||
// NoiseBoundTests.MeasureHessian (slow, explicit) takes the largest Hessian norm (central
|
||||
// differences of snoise_grad, largest absolute eigenvalue) over 1e6 seeded points, refines the 1e4
|
||||
// largest by ascent, and multiplies the refined maximum by 1.10;
|
||||
// NoiseBoundTests.MeasureHessian (slow, explicit) takes the largest Hessian norm (snoise_hessian,
|
||||
// largest absolute eigenvalue) over 1e6 seeded points, refines the 1e4 largest by ascent, and
|
||||
// multiplies the refined maximum by 1.10 (50.5838 measured, 55.642143 pinned; the central
|
||||
// differences of snoise_grad it replaced read 50.955 here, their roundoff at h = 1e-3);
|
||||
// NoiseBoundTests.HessianConstantPinsSampledCurvature pins this value against a fast re-measure and
|
||||
// the ascent from MeasureHessian's witness. Empirical with a margin, not a proof. Random balls
|
||||
// cannot see a constant 20% low, so the defence is those two curvature tests plus
|
||||
|
|
@ -31,8 +32,8 @@ public static partial class math
|
|||
// curvature is realised if the constant is under it. The margin over the function: a scan of the
|
||||
// whole 289^3 hash period found a realised maximum of 50.58, and bounding each corner's gradient
|
||||
// independently over every hash configuration gives 52.22 (Soul pass s1). A change to the snoise
|
||||
// kernel must re-run MeasureHessian and re-pin it.
|
||||
public const float SNOISE_HESSIAN = 56.050385f;
|
||||
// kernel must re-run MeasureHessian and re-pin it. SNOISE_THIRD (math.Span.cs) is the next-order pin.
|
||||
public const float SNOISE_HESSIAN = 55.642143f;
|
||||
|
||||
/// <summary>The point form (x, 0, 0): the value x at every eps. Exact.</summary>
|
||||
public static float3 af_point(float value) => new(value, 0.0f, 0.0f);
|
||||
|
|
|
|||
397
packages/cultmath/src/CultMath/math.Cycles.cs
Normal file
397
packages/cultmath/src/CultMath/math.Cycles.cs
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Cycles node semantics: what Blender's Cycles renderer computes for the Math, Map Range, Clamp, Mix and
|
||||
// RGB to BW nodes and for a Color Ramp lookup, ported function by function from the Cycles kernel
|
||||
// (intern/cycles of the Blender v5.2.2 tag; SPDX-FileCopyrightText: 2011-2022 Blender Foundation, Apache-2.0):
|
||||
// kernel/svm/{math_util,map_range,clamp,mix,color_util,ramp,convert}.h, util/math_base.h, util/color.h and
|
||||
// util/math_float3.h. Each function's comment names its Cycles source. Modified by GameCult: Cycles C++
|
||||
// becomes C#, a function per node operation, and the node's socket defaults and clamp options are left to
|
||||
// the caller. This file is Apache-2.0 (section 4: it was changed); the rest of CultMath is MIT, except the
|
||||
// MPL-2.0 Phacelle files (see THIRD-PARTY-NOTICES.md). shaders/CultMath.Cycles.hlsl mirrors it bit for bit.
|
||||
//
|
||||
// Operands are finite floats. NaN and infinite operands are outside the oracle's reach and the mirror's.
|
||||
// CyclesOracleTests holds every function to the results of Blender 5.2.2's own CPU Cycles.
|
||||
|
||||
namespace CultMath;
|
||||
|
||||
public static partial class math
|
||||
{
|
||||
// FLT_EPSILON, the least epsilon a Compare node honours.
|
||||
private const float CYCLES_FLT_EPSILON = 1.1920929e-07f;
|
||||
|
||||
/// <summary>The scene-linear red luminance coefficient Cycles' RGB to BW node reads from Blender 5.2.2's default OCIO config.</summary>
|
||||
public const float CYCLES_RGB_TO_Y_R = 0.212639093f;
|
||||
|
||||
/// <summary>The scene-linear green luminance coefficient Cycles' RGB to BW node reads from Blender 5.2.2's default OCIO config.</summary>
|
||||
public const float CYCLES_RGB_TO_Y_G = 0.715169132f;
|
||||
|
||||
/// <summary>The scene-linear blue luminance coefficient Cycles' RGB to BW node reads from Blender 5.2.2's default OCIO config.</summary>
|
||||
public const float CYCLES_RGB_TO_Y_B = 0.0721927434f;
|
||||
|
||||
// ---- Math node (kernel/svm/math_util.h svm_math; util/math_base.h) ----
|
||||
|
||||
/// <summary>Math node Divide (safe_divide): a / b, or 0 when b is 0.</summary>
|
||||
public static float cycles_divide(float a, float b) => b != 0.0f ? a / b : 0.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Math node Power (safe_powf with compatible_powf): 0 for a negative base with a fractional exponent,
|
||||
/// 1 for exponent 0, 0 for base 0. A negative base takes an integral exponent's parity.
|
||||
/// </summary>
|
||||
public static float cycles_power(float a, float b)
|
||||
{
|
||||
var whole = b >= 0.0f ? floor(b) : ceil(b);
|
||||
if (a < 0.0f && b != whole)
|
||||
return 0.0f;
|
||||
if (b == 0.0f)
|
||||
return 1.0f;
|
||||
if (a == 0.0f)
|
||||
return 0.0f;
|
||||
if (a < 0.0f)
|
||||
{
|
||||
var magnitude = pow(-a, b);
|
||||
// Odd exactly when the remainder is 1: an infinite exponent leaves NaN here and is even, as powf(a, +-inf) is never negative.
|
||||
return b - 2.0f * floor(0.5f * b) == 1.0f ? -magnitude : magnitude;
|
||||
}
|
||||
|
||||
return pow(a, b);
|
||||
}
|
||||
|
||||
/// <summary>Math node Logarithm (safe_logf): log(a) / log(b), or 0 when a or b is not positive or log(b) is 0.</summary>
|
||||
public static float cycles_logarithm(float a, float b)
|
||||
{
|
||||
if (a <= 0.0f || b <= 0.0f)
|
||||
return 0.0f;
|
||||
var denominator = log(b);
|
||||
return denominator != 0.0f ? log(a) / denominator : 0.0f;
|
||||
}
|
||||
|
||||
/// <summary>Math node Square Root (safe_sqrtf): the square root of max(a, 0).</summary>
|
||||
public static float cycles_sqrt(float a) => sqrt(max(a, 0.0f));
|
||||
|
||||
/// <summary>Math node Inverse Square Root (inversesqrtf): 1 / sqrt(a), or 0 when a is not positive.</summary>
|
||||
public static float cycles_inverse_sqrt(float a) => a > 0.0f ? 1.0f / sqrt(a) : 0.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Math node Truncated Modulo (safe_modulo), the remainder with the dividend's sign, exactly as fmodf computes
|
||||
/// it, or 0 when b is 0. GLSL has no fmod, and a - b trunc(a / b) is wrong where the quotient rounds up to an
|
||||
/// integer or the product rounds (3 mod 0.3 is 0.29999995, not 0). So this subtracts the largest power-of-two
|
||||
/// multiple of the divisor not above the remainder until the remainder is below the divisor: every step is an
|
||||
/// exact subtraction, as in fmod. The loops are bounded by the binades a float spans.
|
||||
/// </summary>
|
||||
public static float cycles_modulo(float a, float b)
|
||||
{
|
||||
var remainder = abs(a);
|
||||
var divisor = abs(b);
|
||||
for (var steps = 0; steps < 300 && remainder >= divisor && divisor > 0.0f; steps++)
|
||||
{
|
||||
var multiple = divisor;
|
||||
for (var doublings = 0; doublings < 300 && multiple * 2.0f <= remainder; doublings++)
|
||||
multiple = multiple * 2.0f;
|
||||
remainder = remainder - multiple;
|
||||
}
|
||||
|
||||
return b != 0.0f ? (a < 0.0f ? -remainder : remainder) : 0.0f;
|
||||
}
|
||||
|
||||
/// <summary>Math node Floored Modulo (safe_floored_modulo): a - floor(a / b) b, or 0 when b is 0, with one IEEE single
|
||||
/// rounding per operation (the reference of ruling cycles-kernel-reference-ruled). A build that contracts
|
||||
/// a - floor(a / b) b into one fused operation differs by at most 2^-23 (|a / b| + 1) |b| (measured 0.91 of 2^-24 (|a| + |b|)
|
||||
/// on Blender 5.2.2's AVX2 kernel); a fast-math build may also move floor(a / b) by one near an integer quotient,
|
||||
/// so the result differs by a whole |b|. See tools/cycles-oracle/README.md.</summary>
|
||||
public static float cycles_floored_modulo(float a, float b) => b != 0.0f ? a - floor(a / b) * b : 0.0f;
|
||||
|
||||
/// <summary>Math node Wrap (wrapf; note the argument order): value wrapped into [min_value, max_value), or min_value for an empty range,
|
||||
/// with one IEEE single rounding per operation. A build that contracts value - range floor(...) into one fused operation differs by at
|
||||
/// most 2^-23 (|value| + |min_value| + |range|) (measured 0.77 of 2^-24 on the AVX2 kernel); a fast-math build may also move floor by one,
|
||||
/// a whole |range|. See tools/cycles-oracle/README.md.</summary>
|
||||
public static float cycles_wrap(float value, float max_value, float min_value)
|
||||
{
|
||||
var range = max_value - min_value;
|
||||
return range != 0.0f ? value - (range * floor((value - min_value) / range)) : min_value;
|
||||
}
|
||||
|
||||
/// <summary>Math node Snap: floor(safe_divide(a, b)) b.</summary>
|
||||
public static float cycles_snap(float a, float b) => floor(cycles_divide(a, b)) * b;
|
||||
|
||||
/// <summary>Math node Ping-Pong (pingpongf): a triangle wave of a with period 2 b, or 0 when b is 0.</summary>
|
||||
public static float cycles_pingpong(float a, float b)
|
||||
{
|
||||
var scaled = (a - b) / (b * 2.0f);
|
||||
return b != 0.0f ? abs((scaled - floor(scaled)) * b * 2.0f - b) : 0.0f;
|
||||
}
|
||||
|
||||
/// <summary>Math node Smooth Minimum (smoothminf): the polynomial smooth minimum of width k, or min(a, b) when k is 0.</summary>
|
||||
public static float cycles_smooth_min(float a, float b, float k)
|
||||
{
|
||||
var h = max(k - abs(a - b), 0.0f) / k;
|
||||
return k != 0.0f ? min(a, b) - h * h * h * k * 0.16666667f : min(a, b);
|
||||
}
|
||||
|
||||
/// <summary>Math node Smooth Maximum: -smoothminf(-a, -b, k).</summary>
|
||||
public static float cycles_smooth_max(float a, float b, float k) => -cycles_smooth_min(-a, -b, k);
|
||||
|
||||
/// <summary>Math node Compare: 1 when a equals b or they differ by at most max(epsilon, FLT_EPSILON), else 0.</summary>
|
||||
public static float cycles_compare(float a, float b, float epsilon) =>
|
||||
(a == b || abs(a - b) <= max(epsilon, CYCLES_FLT_EPSILON)) ? 1.0f : 0.0f;
|
||||
|
||||
/// <summary>Math node Round: floor(a + 0.5).</summary>
|
||||
public static float cycles_round(float a) => floor(a + 0.5f);
|
||||
|
||||
/// <summary>Math node Truncate: floor for a >= 0, ceil otherwise.</summary>
|
||||
public static float cycles_trunc(float a) => a >= 0.0f ? floor(a) : ceil(a);
|
||||
|
||||
/// <summary>Math node Fraction: a - floor(a).</summary>
|
||||
public static float cycles_fract(float a) => a - floor(a);
|
||||
|
||||
/// <summary>Math node Sign (compatible_signf): 0 for 0, -1 below, 1 above.</summary>
|
||||
public static float cycles_sign(float a) => a == 0.0f ? 0.0f : (a < 0.0f ? -1.0f : 1.0f);
|
||||
|
||||
/// <summary>Math node Arcsine (safe_asinf, util/math_base.h): asin of a clamped to [-1, 1].</summary>
|
||||
public static float cycles_arcsine(float a) => asin(clamp(a, -1.0f, 1.0f));
|
||||
|
||||
/// <summary>Math node Arccosine (safe_acosf, util/math_base.h): acos of a clamped to [-1, 1].</summary>
|
||||
public static float cycles_arccosine(float a) => acos(clamp(a, -1.0f, 1.0f));
|
||||
|
||||
/// <summary>Math node Arctan2 (compatible_atan2, util/math_base.h; a is y, b is x): 0 at the origin, else atan2(a, b).</summary>
|
||||
public static float cycles_arctan2(float a, float b) => (b == 0.0f && a == 0.0f) ? 0.0f : atan2(a, b);
|
||||
|
||||
// ---- Map Range node (kernel/svm/map_range.h svm_node_map_range) ----
|
||||
// A Map Range node's Clamp option is a Clamp node of type Range on its result (MapRangeNode::expand):
|
||||
// compose cycles_clamp_range over these.
|
||||
|
||||
/// <summary>Map Range, Linear: to_min + (value - from_min) / (from_max - from_min) (to_max - to_min), or 0 when from_max equals from_min.</summary>
|
||||
public static float cycles_map_range_linear(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
var factor = (value - from_min) / (from_max - from_min);
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0f;
|
||||
}
|
||||
|
||||
/// <summary>Map Range, Stepped: the linear factor quantized to `steps` steps (0 when steps is not positive), or 0 when from_max equals from_min.</summary>
|
||||
public static float cycles_map_range_stepped(float value, float from_min, float from_max, float to_min, float to_max, float steps)
|
||||
{
|
||||
var factor = (value - from_min) / (from_max - from_min);
|
||||
factor = steps > 0.0f ? floor(factor * (steps + 1.0f)) / steps : 0.0f;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map Range, Smooth Step: the value goes through smoothstep(from_min, from_max, .), flipped when from_min
|
||||
/// is above from_max (util/math_base.h smoothstep), or 0 when from_max equals from_min.
|
||||
/// </summary>
|
||||
public static float cycles_map_range_smoothstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
var low = min(from_min, from_max);
|
||||
var high = max(from_min, from_max);
|
||||
var t = (value - low) / (high - low);
|
||||
var eased = value < low ? 0.0f : (value >= high ? 1.0f : (3.0f - 2.0f * t) * (t * t));
|
||||
var factor = from_min > from_max ? 1.0f - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0f;
|
||||
}
|
||||
|
||||
/// <summary>Map Range, Smoother Step (map_range.h smootherstep), flipped when from_min is above from_max, or 0 when from_max equals from_min.</summary>
|
||||
public static float cycles_map_range_smootherstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
var low = min(from_min, from_max);
|
||||
var high = max(from_min, from_max);
|
||||
var x = clamp(cycles_divide(value - low, high - low), 0.0f, 1.0f);
|
||||
var eased = x * x * x * (x * (x * 6.0f - 15.0f) + 10.0f);
|
||||
var factor = from_min > from_max ? 1.0f - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0f;
|
||||
}
|
||||
|
||||
// ---- Clamp node (kernel/svm/clamp.h svm_node_clamp) ----
|
||||
|
||||
/// <summary>Clamp node, Min Max: clamp(value, min_value, max_value).</summary>
|
||||
public static float cycles_clamp_minmax(float value, float min_value, float max_value) => clamp(value, min_value, max_value);
|
||||
|
||||
/// <summary>Clamp node, Range: as Min Max, with the bounds swapped when min_value is above max_value.</summary>
|
||||
public static float cycles_clamp_range(float value, float min_value, float max_value) =>
|
||||
min_value > max_value ? clamp(value, max_value, min_value) : clamp(value, min_value, max_value);
|
||||
|
||||
// ---- Mix node (kernel/svm/mix.h, color_util.h) ----
|
||||
// Factor clamping (svm_node_mix_color's saturatef) and result clamping (saturate) are the caller's.
|
||||
|
||||
/// <summary>Mix node, Float (svm_node_mix_float): a (1 - t) + b t.</summary>
|
||||
public static float cycles_mix_float(float t, float a, float b) => a * (1.0f - t) + b * t;
|
||||
|
||||
/// <summary>Mix node, Vector, uniform factor (svm_node_mix_vector): a (1 - t) + b t per component.</summary>
|
||||
public static float3 cycles_mix_vector(float t, float3 a, float3 b) => a * (new float3(1.0f, 1.0f, 1.0f) - t) + b * t;
|
||||
|
||||
/// <summary>Mix node, Mix blend mode (svm_mix_blend).</summary>
|
||||
public static float3 cycles_mix_blend(float t, float3 col1, float3 col2) => col1 + t * (col2 - col1);
|
||||
|
||||
/// <summary>Mix node, Add blend mode (svm_mix_add).</summary>
|
||||
public static float3 cycles_mix_add(float t, float3 col1, float3 col2) => col1 + t * ((col1 + col2) - col1);
|
||||
|
||||
/// <summary>Mix node, Multiply blend mode (svm_mix_mul).</summary>
|
||||
public static float3 cycles_mix_mul(float t, float3 col1, float3 col2) => col1 + t * (col1 * col2 - col1);
|
||||
|
||||
/// <summary>Mix node, Screen blend mode (svm_mix_screen).</summary>
|
||||
public static float3 cycles_mix_screen(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
var one = new float3(1.0f, 1.0f, 1.0f);
|
||||
return one - (new float3(tm, tm, tm) + t * (one - col2)) * (one - col1);
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Overlay blend mode (svm_mix_overlay).</summary>
|
||||
public static float3 cycles_mix_overlay(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
return new float3(
|
||||
col1.x < 0.5f ? col1.x * (tm + 2.0f * t * col2.x) : 1.0f - (tm + 2.0f * t * (1.0f - col2.x)) * (1.0f - col1.x),
|
||||
col1.y < 0.5f ? col1.y * (tm + 2.0f * t * col2.y) : 1.0f - (tm + 2.0f * t * (1.0f - col2.y)) * (1.0f - col1.y),
|
||||
col1.z < 0.5f ? col1.z * (tm + 2.0f * t * col2.z) : 1.0f - (tm + 2.0f * t * (1.0f - col2.z)) * (1.0f - col1.z));
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Subtract blend mode (svm_mix_sub).</summary>
|
||||
public static float3 cycles_mix_sub(float t, float3 col1, float3 col2) => col1 + t * ((col1 - col2) - col1);
|
||||
|
||||
/// <summary>Mix node, Divide blend mode (svm_mix_div): a channel whose col2 is 0 keeps col1.</summary>
|
||||
public static float3 cycles_mix_div(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
return new float3(
|
||||
col2.x != 0.0f ? tm * col1.x + t * col1.x / col2.x : col1.x,
|
||||
col2.y != 0.0f ? tm * col1.y + t * col1.y / col2.y : col1.y,
|
||||
col2.z != 0.0f ? tm * col1.z + t * col1.z / col2.z : col1.z);
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Difference blend mode (svm_mix_diff).</summary>
|
||||
public static float3 cycles_mix_diff(float t, float3 col1, float3 col2) => col1 + t * (abs(col1 - col2) - col1);
|
||||
|
||||
/// <summary>Mix node, Exclusion blend mode (svm_mix_exclusion).</summary>
|
||||
public static float3 cycles_mix_exclusion(float t, float3 col1, float3 col2) =>
|
||||
max(col1 + t * ((col1 + col2 - 2.0f * col1 * col2) - col1), new float3(0.0f, 0.0f, 0.0f));
|
||||
|
||||
/// <summary>Mix node, Darken blend mode (svm_mix_dark).</summary>
|
||||
public static float3 cycles_mix_dark(float t, float3 col1, float3 col2) => col1 + t * (min(col1, col2) - col1);
|
||||
|
||||
/// <summary>Mix node, Lighten blend mode (svm_mix_light).</summary>
|
||||
public static float3 cycles_mix_light(float t, float3 col1, float3 col2) => col1 + t * (max(col1, col2) - col1);
|
||||
|
||||
/// <summary>Mix node, Dodge blend mode (svm_mix_dodge).</summary>
|
||||
public static float3 cycles_mix_dodge(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var divisor = new float3(1.0f, 1.0f, 1.0f) - t * col2;
|
||||
return new float3(
|
||||
col1.x != 0.0f ? (divisor.x <= 0.0f ? 1.0f : min(col1.x / divisor.x, 1.0f)) : col1.x,
|
||||
col1.y != 0.0f ? (divisor.y <= 0.0f ? 1.0f : min(col1.y / divisor.y, 1.0f)) : col1.y,
|
||||
col1.z != 0.0f ? (divisor.z <= 0.0f ? 1.0f : min(col1.z / divisor.z, 1.0f)) : col1.z);
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Burn blend mode (svm_mix_burn).</summary>
|
||||
public static float3 cycles_mix_burn(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
var divisor = tm + t * col2;
|
||||
var burnt = new float3(1.0f, 1.0f, 1.0f) - (new float3(1.0f, 1.0f, 1.0f) - col1) / divisor;
|
||||
return new float3(
|
||||
divisor.x <= 0.0f ? 0.0f : clamp(burnt.x, 0.0f, 1.0f),
|
||||
divisor.y <= 0.0f ? 0.0f : clamp(burnt.y, 0.0f, 1.0f),
|
||||
divisor.z <= 0.0f ? 0.0f : clamp(burnt.z, 0.0f, 1.0f));
|
||||
}
|
||||
|
||||
/// <summary>RGB to HSV as Cycles computes it (util/color.h rgb_to_hsv): hue in [0, 1), saturation, value.</summary>
|
||||
public static float3 cycles_rgb_to_hsv(float3 rgb)
|
||||
{
|
||||
var cmax = max(rgb.x, max(rgb.y, rgb.z));
|
||||
var cmin = min(rgb.x, min(rgb.y, rgb.z));
|
||||
var cdelta = cmax - cmin;
|
||||
var s = cmax != 0.0f ? cdelta / cmax : 0.0f;
|
||||
var c = (new float3(cmax, cmax, cmax) - rgb) / cdelta;
|
||||
var h = rgb.x == cmax ? c.z - c.y : (rgb.y == cmax ? 2.0f + c.x - c.z : 4.0f + c.y - c.x);
|
||||
h = h * 0.16666667f; // Cycles' kernel is built with reciprocal math: h /= 6.0f computes h * (1 / 6.0f).
|
||||
h = h < 0.0f ? h + 1.0f : h;
|
||||
return new float3(s != 0.0f ? h : 0.0f, s, cmax);
|
||||
}
|
||||
|
||||
/// <summary>HSV to RGB as Cycles computes it (util/color.h hsv_to_rgb).</summary>
|
||||
public static float3 cycles_hsv_to_rgb(float3 hsv)
|
||||
{
|
||||
var h = hsv.x == 1.0f ? 0.0f : hsv.x;
|
||||
h = h * 6.0f;
|
||||
var i = floor(h);
|
||||
var f = h - i;
|
||||
var p = hsv.z * (1.0f - hsv.y);
|
||||
var q = hsv.z * (1.0f - (hsv.y * f));
|
||||
var t = hsv.z * (1.0f - (hsv.y * (1.0f - f)));
|
||||
var rgb = i == 0.0f ? new float3(hsv.z, t, p) :
|
||||
(i == 1.0f ? new float3(q, hsv.z, p) :
|
||||
(i == 2.0f ? new float3(p, hsv.z, t) :
|
||||
(i == 3.0f ? new float3(p, q, hsv.z) :
|
||||
(i == 4.0f ? new float3(t, p, hsv.z) : new float3(hsv.z, p, q)))));
|
||||
return hsv.y != 0.0f ? rgb : new float3(hsv.z, hsv.z, hsv.z);
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Hue blend mode (svm_mix_hue).</summary>
|
||||
public static float3 cycles_mix_hue(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var hsv2 = cycles_rgb_to_hsv(col2);
|
||||
var hsv = cycles_rgb_to_hsv(col1);
|
||||
var shifted = cycles_hsv_to_rgb(new float3(hsv2.x, hsv.y, hsv.z));
|
||||
return hsv2.y != 0.0f ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Saturation blend mode (svm_mix_sat).</summary>
|
||||
public static float3 cycles_mix_sat(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
var hsv = cycles_rgb_to_hsv(col1);
|
||||
var hsv2 = cycles_rgb_to_hsv(col2);
|
||||
return hsv.y != 0.0f ? cycles_hsv_to_rgb(new float3(hsv.x, tm * hsv.y + t * hsv2.y, hsv.z)) : col1;
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Value blend mode (svm_mix_val).</summary>
|
||||
public static float3 cycles_mix_val(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
var hsv = cycles_rgb_to_hsv(col1);
|
||||
var hsv2 = cycles_rgb_to_hsv(col2);
|
||||
return cycles_hsv_to_rgb(new float3(hsv.x, hsv.y, tm * hsv.z + t * hsv2.z));
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Color blend mode (svm_mix_color).</summary>
|
||||
public static float3 cycles_mix_color(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var hsv2 = cycles_rgb_to_hsv(col2);
|
||||
var hsv = cycles_rgb_to_hsv(col1);
|
||||
var shifted = cycles_hsv_to_rgb(new float3(hsv2.x, hsv2.y, hsv.z));
|
||||
return hsv2.y != 0.0f ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Soft Light blend mode (svm_mix_soft).</summary>
|
||||
public static float3 cycles_mix_soft(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
var one = new float3(1.0f, 1.0f, 1.0f);
|
||||
var scr = one - (one - col2) * (one - col1);
|
||||
return tm * col1 + t * ((one - col1) * col2 * col1 + col1 * scr);
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Linear Light blend mode (svm_mix_linear).</summary>
|
||||
public static float3 cycles_mix_linear(float t, float3 col1, float3 col2) =>
|
||||
col1 + t * (2.0f * col2 + new float3(-1.0f, -1.0f, -1.0f));
|
||||
|
||||
// ---- RGB to BW node (kernel/svm/convert.h NODE_CONVERT_CF) ----
|
||||
|
||||
/// <summary>RGB to BW node: the scene-linear luminance of a colour.</summary>
|
||||
public static float cycles_rgb_to_bw(float3 color) =>
|
||||
color.x * CYCLES_RGB_TO_Y_R + color.y * CYCLES_RGB_TO_Y_G + color.z * CYCLES_RGB_TO_Y_B;
|
||||
|
||||
// ---- Color Ramp lookup (kernel/svm/ramp.h rgb_ramp_lookup, without extrapolation) ----
|
||||
|
||||
/// <summary>
|
||||
/// Where a Color Ramp of `size` table entries is read at t: (index, weight), the lower entry and the
|
||||
/// weight of the next one. The colour is table[index] when weight is 0 and
|
||||
/// (1 - weight) table[index] + weight table[index + 1] otherwise. The ramp table itself is the caller's;
|
||||
/// Blender's holds 257 entries, the ramp sampled at i / 256.
|
||||
/// </summary>
|
||||
public static float2 cycles_ramp_index(float t, int size)
|
||||
{
|
||||
var f = (t > 0.0f ? (t < 1.0f ? t : 1.0f) : 0.0f) * (float)(size - 1);
|
||||
var index = clamp((int)f, 0, size - 1);
|
||||
var lower = (float)index;
|
||||
return new float2(lower, f - lower);
|
||||
}
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/math.Cycles.cs.meta
Normal file
2
packages/cultmath/src/CultMath/math.Cycles.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 0484788fead24b7cb0b8b373bf7e1f9d
|
||||
123
packages/cultmath/src/CultMath/math.Span.cs
Normal file
123
packages/cultmath/src/CultMath/math.Span.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
namespace CultMath;
|
||||
|
||||
public static partial class math
|
||||
{
|
||||
// An upper bound on the operator norm of the third-derivative tensor of snoise(float3):
|
||||
// max over unit u of |D^3 snoise(p)[u, u, u]|, the next order after SNOISE_LIPSCHITZ (gradient)
|
||||
// and SNOISE_HESSIAN (curvature). Provenance: NoiseBoundTests.MeasureThird (slow, explicit) takes
|
||||
// the largest such norm over 1e6 seeded points of [-256, 256]^3 (central differences of
|
||||
// snoise_hessian at h = 1e-3, then an ascent over unit vectors for the tensor's operator norm),
|
||||
// refines the 1e4 largest by ascent over the point, and multiplies the maximum by 1.10; it prints
|
||||
// the witness start. NoiseBoundTests.ThirdConstantPinsSampled pins the value against a fast
|
||||
// re-measure and the ascent from that witness. Measured on the tied kernel (snoise_grad's order),
|
||||
// not on any pre-tie splice. Measured 488.1047 (a dense sweep of 20,000 directions at the witness
|
||||
// found 99.9998% of the tensor norm there). Empirical with a margin, not a proof. A change to the snoise kernel
|
||||
// must re-run MeasureThird and re-pin it.
|
||||
public const float SNOISE_THIRD = 536.9152f;
|
||||
|
||||
// Analytic Hessian of snoise(float3): the symmetric matrix of second partial derivatives, same
|
||||
// lattice, hash and tie order as snoise_grad, whose kernel it differentiates once more. Every local
|
||||
// up through p0..p3 and x0..x3 is snoise_grad's own derivation verbatim. Per corner, with
|
||||
// m = max(0.5 - dot(x,x), 0) and s = dot(p,x), the corner's gradient is -8 m^3 s x + m^4 p, so
|
||||
// d/dx_j of its i-th component is 48 m^2 s x_i x_j - 8 m^3 (p_j x_i + p_i x_j + s delta_ij) (the
|
||||
// derivative of m^3 is -6 m^2 x_j, of s is p_j); summed over the four corners and scaled by the
|
||||
// same 105. A clamped corner (m = 0) contributes nothing, and the m^2 and m^3 factors make the
|
||||
// contribution vanish continuously as it clamps. Each off-diagonal entry is computed once and
|
||||
// placed twice, so the matrix is its own transpose bit for bit. A float3x3 is built from its nine
|
||||
// scalars, which the GLSL lowering reads as columns and HLSL as rows: the matrix is symmetric.
|
||||
public static float3x3 snoise_hessian(float3 value)
|
||||
{
|
||||
const float cx = 1.0f / 6.0f;
|
||||
const float cy = 1.0f / 3.0f;
|
||||
var i = floor(value + dot(value, new float3(cy, cy, cy)));
|
||||
var x0 = value - i + dot(i, new float3(cx, cx, cx));
|
||||
var g = new float3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0f - step(x0.z, x0.x));
|
||||
var l = 1.0f - g;
|
||||
var lzxy = new float3(l.z, l.x, l.y);
|
||||
var i1 = min(g, lzxy);
|
||||
var i2 = max(g, lzxy);
|
||||
var x1 = x0 - i1 + cx;
|
||||
var x2 = x0 - i2 + cy;
|
||||
var x3 = x0 - 0.5f;
|
||||
|
||||
i = snoise_mod289(i);
|
||||
var p = snoise_permute(snoise_permute(snoise_permute(
|
||||
i.z + new float4(0.0f, i1.z, i2.z, 1.0f)) + i.y + new float4(0.0f, i1.y, i2.y, 1.0f)) + i.x + new float4(0.0f, i1.x, i2.x, 1.0f));
|
||||
|
||||
const float n = 0.142857142857f;
|
||||
var ns = new float3(2.0f * n, 0.5f * n - 1.0f, n);
|
||||
var j = p - 49.0f * floor(p * ns.z * ns.z);
|
||||
var xs = floor(j * ns.z);
|
||||
var ys = floor(j - 7.0f * xs);
|
||||
var x = xs * ns.x + ns.y;
|
||||
var y = ys * ns.x + ns.y;
|
||||
var h = 1.0f - abs(x) - abs(y);
|
||||
|
||||
var b0 = new float4(x.x, x.y, y.x, y.y);
|
||||
var b1 = new float4(x.z, x.w, y.z, y.w);
|
||||
var s0 = floor(b0) * 2.0f + 1.0f;
|
||||
var s1 = floor(b1) * 2.0f + 1.0f;
|
||||
var sh = -step(h, 0.0f);
|
||||
var a0 = new float4(b0.x, b0.z, b0.y, b0.w) + new float4(s0.x, s0.z, s0.y, s0.w) * new float4(sh.x, sh.x, sh.y, sh.y);
|
||||
var a1 = new float4(b1.x, b1.z, b1.y, b1.w) + new float4(s1.x, s1.z, s1.y, s1.w) * new float4(sh.z, sh.z, sh.w, sh.w);
|
||||
|
||||
var p0 = new float3(a0.x, a0.y, h.x);
|
||||
var p1 = new float3(a0.z, a0.w, h.y);
|
||||
var p2 = new float3(a1.x, a1.y, h.z);
|
||||
var p3 = new float3(a1.z, a1.w, h.w);
|
||||
var norm = 1.79284291400159f - 0.85373472095314f * new float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3));
|
||||
p0 *= norm.x;
|
||||
p1 *= norm.y;
|
||||
p2 *= norm.z;
|
||||
p3 *= norm.w;
|
||||
|
||||
var m0 = max(0.5f - new float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0f);
|
||||
var m2 = m0 * m0;
|
||||
var m3 = m2 * m0;
|
||||
var px = new float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3));
|
||||
var wide = 48.0f * m2 * px;
|
||||
var narrow = 8.0f * m3;
|
||||
|
||||
// One component of every corner's x and p per register.
|
||||
var cxx = new float4(x0.x, x1.x, x2.x, x3.x);
|
||||
var cxy = new float4(x0.y, x1.y, x2.y, x3.y);
|
||||
var cxz = new float4(x0.z, x1.z, x2.z, x3.z);
|
||||
var cpx = new float4(p0.x, p1.x, p2.x, p3.x);
|
||||
var cpy = new float4(p0.y, p1.y, p2.y, p3.y);
|
||||
var cpz = new float4(p0.z, p1.z, p2.z, p3.z);
|
||||
|
||||
var hxx = 105.0f * (dot(wide * cxx, cxx) - dot(narrow, 2.0f * cpx * cxx + px));
|
||||
var hyy = 105.0f * (dot(wide * cxy, cxy) - dot(narrow, 2.0f * cpy * cxy + px));
|
||||
var hzz = 105.0f * (dot(wide * cxz, cxz) - dot(narrow, 2.0f * cpz * cxz + px));
|
||||
var hxy = 105.0f * (dot(wide * cxx, cxy) - dot(narrow, cpx * cxy + cpy * cxx));
|
||||
var hxz = 105.0f * (dot(wide * cxx, cxz) - dot(narrow, cpx * cxz + cpz * cxx));
|
||||
var hyz = 105.0f * (dot(wide * cxy, cxz) - dot(narrow, cpy * cxz + cpz * cxy));
|
||||
return new float3x3(hxx, hxy, hxz, hxy, hyy, hyz, hxz, hyz, hzz);
|
||||
}
|
||||
|
||||
// The integral over [t0, t1] of clamp(a + b t, lo, hi) dt, in closed form. The line meets lo at
|
||||
// (lo - a) / b and hi at (hi - a) / b; the span is cut at those two crossings into the stretch held
|
||||
// at the bound the line starts beyond, the stretch on the ramp (a trapezoid: its length times the
|
||||
// line's value at the stretch's midpoint), and the stretch held at the far bound. b = 0 is the
|
||||
// constant clamp(a, lo, hi); t1 < t0 is the negative of the integral over [t1, t0]; lo above hi
|
||||
// clamps to hi everywhere, as clamp does. A crossing that overflows (b tiny) lies past the span and
|
||||
// cuts nothing.
|
||||
public static float integral_clamp_linear(float a, float b, float lo, float hi, float t0, float t1)
|
||||
{
|
||||
if (b == 0.0f)
|
||||
return clamp(a, lo, hi) * (t1 - t0);
|
||||
|
||||
lo = min(lo, hi);
|
||||
var direction = t1 < t0 ? -1.0f : 1.0f;
|
||||
var start = min(t0, t1);
|
||||
var stop = max(t0, t1);
|
||||
var atLo = (lo - a) / b;
|
||||
var atHi = (hi - a) / b;
|
||||
var enter = clamp(min(atLo, atHi), start, stop);
|
||||
var leave = clamp(max(atLo, atHi), start, stop);
|
||||
var before = b > 0.0f ? lo : hi;
|
||||
var after = b > 0.0f ? hi : lo;
|
||||
var total = before * (enter - start) + (a + b * (0.5f * (enter + leave))) * (leave - enter) + after * (stop - leave);
|
||||
return direction * total;
|
||||
}
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/math.Span.cs.meta
Normal file
2
packages/cultmath/src/CultMath/math.Span.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 1466848dfbfc420ab4f3f67090944a67
|
||||
460
packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs
Normal file
460
packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
using System.Reflection;
|
||||
using System.Text.Json.Nodes;
|
||||
using Xunit;
|
||||
|
||||
namespace CultMath.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// math.cycles_* and the shader mirror's cultmath_cycles_* (shaders/CultMath.Cycles.hlsl, compiled as C#) against
|
||||
/// the results of Blender 5.2.2's own Cycles on the CPU, the reference of ruling
|
||||
/// cultmath-tapes:ruling:cycles-kernel-reference-ruled: Cycles' formulas with one IEEE single rounding per
|
||||
/// operation. The fixtures under fixtures/cycles-oracle are written by
|
||||
/// tools/cycles-oracle/oracle.py (never by hand): each case is the operands and the node's baked results as
|
||||
/// float32 bit patterns. Every function is compared at every case within the tolerance named in
|
||||
/// <see cref="Tolerances"/>; a function not named there is held to 0 ulp. Signed zeros are not distinguished (-0
|
||||
/// equals 0), and NaN equals NaN. The fixtures come from Cycles' SSE4.2 CPU kernel, which does not fuse
|
||||
/// multiply-adds as C# does not (oracle.py checks this before it bakes); other builds are other tolerance classes
|
||||
/// (tools/cycles-oracle/README.md). Cycles' film writes 0 for an infinite or
|
||||
/// NaN result, so a case whose result here is not finite and whose fixture result is 0 holds nothing to compare.
|
||||
/// </summary>
|
||||
public sealed class CyclesOracleTests
|
||||
{
|
||||
// The ulp distance a function is allowed from the reference. Empty: the fixtures are the SSE4.2 kernel's results
|
||||
// and every function equals them, power and logarithm included (glibc's powf and logf, 0 ulp from MathF on Linux).
|
||||
// An entry needs a measured case on the reference named in it (a failing case's bits and ulp); other builds'
|
||||
// tolerances belong to the class table in tools/cycles-oracle/README.md, not here.
|
||||
private static readonly Dictionary<string, (int Ulp, string Measured)> Tolerances = new();
|
||||
|
||||
private const string CSharp = "C#";
|
||||
private const string Mirror = "HLSL mirror";
|
||||
|
||||
private static readonly Lazy<(object Shader, Type Type)> ShaderMirror = new(() =>
|
||||
{
|
||||
var (assembly, errors) = HlslSourceCompatibilityTests.CompileShaderMirror();
|
||||
Assert.True(assembly is not null, string.Join(Environment.NewLine, errors));
|
||||
var type = assembly!.GetType("CultMathHlsl.HlslShader")!;
|
||||
return (Activator.CreateInstance(type)!, type);
|
||||
});
|
||||
|
||||
/// <summary>One implementation of the Cycles functions: CultMath's C# or the HLSL text compiled as C#.</summary>
|
||||
private static object Call(string target, string function, params object[] arguments)
|
||||
{
|
||||
if (target == CSharp)
|
||||
return Method(function).Invoke(null, arguments)!;
|
||||
var (shader, type) = ShaderMirror.Value;
|
||||
var method = type.GetMethod("cultmath_" + function, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("no mirror function cultmath_" + function);
|
||||
return method.Invoke(shader, arguments)!;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> Families() =>
|
||||
from family in new[] { "math", "map-range", "clamp", "mix", "rgb-to-bw" }
|
||||
from target in new[] { CSharp, Mirror }
|
||||
select new object[] { family, target };
|
||||
|
||||
public static IEnumerable<object[]> Targets() => new[] { CSharp, Mirror }.Select(target => new object[] { target });
|
||||
|
||||
private static string FixturePath(string family) =>
|
||||
Path.Combine(HlslSourceCompatibilityTests.FindCultMathRoot(), "tests", "CultMath.Tests", "fixtures", "cycles-oracle", family + ".json");
|
||||
|
||||
private static JsonObject Load(string family) => JsonNode.Parse(File.ReadAllText(FixturePath(family)))!.AsObject();
|
||||
|
||||
private static float Float(string token) => BitConverter.Int32BitsToSingle((int)Convert.ToUInt32(token, 16));
|
||||
|
||||
// A float32 bit pattern as an integer whose order and differences are the floats' order and ulps.
|
||||
private static int Ordered(float value)
|
||||
{
|
||||
var bits = BitConverter.SingleToInt32Bits(value);
|
||||
return bits < 0 ? int.MinValue - bits : bits;
|
||||
}
|
||||
|
||||
private static long Distance(float want, float have) =>
|
||||
want == have || (float.IsNaN(want) && float.IsNaN(have)) ? 0 : float.IsNaN(want) || float.IsNaN(have) ? long.MaxValue : Math.Abs((long)Ordered(want) - Ordered(have));
|
||||
|
||||
// The method takes floats and float3s only; each float3 is three consecutive tokens.
|
||||
private static object[] Arguments(MethodInfo method, string[] tokens)
|
||||
{
|
||||
var values = new List<object>();
|
||||
var next = 0;
|
||||
foreach (var parameter in method.GetParameters())
|
||||
{
|
||||
if (parameter.ParameterType == typeof(float))
|
||||
values.Add(Float(tokens[next++]));
|
||||
else if (parameter.ParameterType == typeof(float3))
|
||||
{
|
||||
values.Add(new float3(Float(tokens[next]), Float(tokens[next + 1]), Float(tokens[next + 2])));
|
||||
next += 3;
|
||||
}
|
||||
else
|
||||
throw new InvalidOperationException($"{method.Name}: parameter type {parameter.ParameterType.Name} is not a fixture operand");
|
||||
}
|
||||
|
||||
// The operands a function does not take (Map Range's Steps, for the three kinds that ignore it) are left unread.
|
||||
Assert.True(next <= tokens.Length, $"{method.Name}: {next} operands wanted, {tokens.Length} in the case");
|
||||
return values.ToArray();
|
||||
}
|
||||
|
||||
private static float[] Flatten(object result) => result switch
|
||||
{
|
||||
float f => new[] { f },
|
||||
float2 v => new[] { v.x, v.y },
|
||||
float3 v => new[] { v.x, v.y, v.z },
|
||||
_ => throw new InvalidOperationException(result.GetType().Name),
|
||||
};
|
||||
|
||||
private static MethodInfo Method(string name) =>
|
||||
typeof(math).GetMethod(name, BindingFlags.Public | BindingFlags.Static) ?? throw new InvalidOperationException("no CultMath function " + name);
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Families))]
|
||||
public void FamilyMatchesBlender(string family, string target)
|
||||
{
|
||||
var fixture = Load(family);
|
||||
Assert.Equal("5.2.2 LTS", (string?)fixture["blender"]);
|
||||
Assert.Equal("d13f752e3b9c", (string?)fixture["build_hash"]);
|
||||
var functions = fixture["functions"]!.AsArray();
|
||||
Assert.NotEmpty(functions);
|
||||
var failures = new List<string>();
|
||||
foreach (var entry in functions)
|
||||
{
|
||||
var name = (string)entry!["function"]!;
|
||||
var method = Method(name);
|
||||
var clamped = (string?)entry["then"] is not null;
|
||||
var allowed = Tolerances.TryGetValue(name, out var tolerance) ? tolerance.Ulp : 0;
|
||||
var worst = 0L;
|
||||
string? worstCase = null;
|
||||
var cases = entry["cases"]!.AsArray();
|
||||
Assert.True(cases.Count >= 1000, $"{name}: only {cases.Count} fixture cases");
|
||||
foreach (var line in cases)
|
||||
{
|
||||
var sides = ((string)line!).Split(" -> ");
|
||||
var operands = sides[0].Split(' ');
|
||||
var have = Flatten(Call(target, name, Arguments(method, operands)));
|
||||
if (clamped)
|
||||
{
|
||||
// A Map Range node's Clamp option is a Clamp node of type Range on its result.
|
||||
have = Flatten(Call(target, "cycles_clamp_range", have[0], Float(operands[3]), Float(operands[4])));
|
||||
}
|
||||
|
||||
var want = sides[1].Split(' ').Select(Float).ToArray();
|
||||
Assert.Equal(want.Length, have.Length);
|
||||
for (var k = 0; k < want.Length; k++)
|
||||
{
|
||||
if (!float.IsFinite(have[k]) && want[k] == 0.0f)
|
||||
continue;
|
||||
var distance = Distance(want[k], have[k]);
|
||||
if (distance > worst)
|
||||
{
|
||||
worst = distance;
|
||||
worstCase = $"{line} ({target} {BitConverter.SingleToInt32Bits(have[k]):x8})";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (worst > allowed)
|
||||
failures.Add($"{target} {name}{(clamped ? " with Clamp" : string.Empty)}: {worst} ulp from Blender, allowed {allowed}; worst case {worstCase}");
|
||||
}
|
||||
|
||||
Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures));
|
||||
}
|
||||
|
||||
private static JsonNode RampEntry(string interpolation) =>
|
||||
Load("ramp")["functions"]!.AsArray().Single(e => e!["node"]!.ToString() == "ShaderNodeValToRGB " + interpolation)!;
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void RampIndexNamesTheEntryBlenderReads(string target)
|
||||
{
|
||||
var entry = RampEntry("CONSTANT");
|
||||
var size = (int)entry["table_entries"]!;
|
||||
var stops = entry["stops"]!.AsArray().Select(stop => (Position: Float((string)stop![0]!), Value: Float((string)stop[1]!))).ToArray();
|
||||
|
||||
// A constant ramp's table: the value of the last stop at or before the entry's position.
|
||||
var table = Enumerable.Range(0, size).Select(i =>
|
||||
{
|
||||
var position = (double)i / (size - 1);
|
||||
var stop = stops.LastOrDefault(s => s.Position <= position);
|
||||
return stop == default ? stops[0].Value : stop.Value;
|
||||
}).ToArray();
|
||||
|
||||
var cases = entry["cases"]!.AsArray().Select(line => ((string)line!).Split(" -> ")).ToArray();
|
||||
Assert.True(table.Distinct().Count() > 30, "the constant ramp's table should name 32 entries apart");
|
||||
foreach (var line in cases)
|
||||
{
|
||||
var lookup = Flatten(Call(target, "cycles_ramp_index", Float(line[0]), size));
|
||||
var index = (int)lookup[0];
|
||||
Assert.InRange(index, 0, size - 1);
|
||||
Assert.True(Distance(Float(line[1]), table[index]) == 0, $"ramp t {line[0]}: index {index} reads {table[index]}, Blender {Float(line[1])}");
|
||||
}
|
||||
|
||||
// Every stop's value appears in the results at its own boundary, so the cases do cross the entries.
|
||||
Assert.True(cases.Select(line => line[1]).Distinct().Count() >= 32, "the cases never cross the stops");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void RampWeightBlendsTheEntryWithTheNextOne(string target)
|
||||
{
|
||||
// A linear ramp from 0 to 1: entry i holds i / (size - 1) to within the float rounding of Blender's table, and
|
||||
// a lookup at (index, weight) reads (index + weight) / (size - 1). A weight that is off by a fraction of a
|
||||
// percent, or an index that is off by one, moves the result by more than the bound.
|
||||
var entry = RampEntry("LINEAR");
|
||||
var size = (int)entry["table_entries"]!;
|
||||
var worst = 0.0;
|
||||
var fractional = 0;
|
||||
foreach (var line in entry["cases"]!.AsArray().Select(line => ((string)line!).Split(" -> ")))
|
||||
{
|
||||
var lookup = Flatten(Call(target, "cycles_ramp_index", Float(line[0]), size));
|
||||
Assert.InRange(lookup[1], 0.0f, 1.0f);
|
||||
fractional += lookup[1] > 0.0f ? 1 : 0;
|
||||
var expected = (lookup[0] + lookup[1]) / (size - 1);
|
||||
worst = Math.Max(worst, Math.Abs(expected - Float(line[1])));
|
||||
}
|
||||
|
||||
Assert.True(fractional > 500, "the cases hold too few interpolated lookups");
|
||||
Assert.True(worst <= Math.ScaleB(1.0, -22), $"a lookup is {worst} from (index + weight) / (size - 1), more than 2^-22");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void ResultsTheFilmCannotShowFollowTheCyclesSource(string target)
|
||||
{
|
||||
// Cycles' film writes 0 for an infinite or NaN result, so the fixtures cannot tell the guards that return 0
|
||||
// from the division that would return an infinity. These are the guards in svm_math (util/math_base.h):
|
||||
// safe_logf, safe_divide and inversesqrtf return 0 where the plain operation is not finite.
|
||||
float One(string function, params float[] arguments) => (float)Call(target, function, arguments.Cast<object>().ToArray());
|
||||
Assert.Equal(0.0f, One("cycles_logarithm", 0.0f, 2.0f));
|
||||
Assert.Equal(0.0f, One("cycles_logarithm", -1.0f, 2.0f));
|
||||
Assert.Equal(0.0f, One("cycles_logarithm", 2.0f, 0.0f));
|
||||
Assert.Equal(0.0f, One("cycles_logarithm", 2.0f, -1.0f));
|
||||
Assert.Equal(0.0f, One("cycles_divide", 1.0f, 0.0f));
|
||||
Assert.Equal(0.0f, One("cycles_inverse_sqrt", 0.0f));
|
||||
Assert.Equal(0.0f, One("cycles_modulo", 1.0f, 0.0f));
|
||||
Assert.Equal(0.0f, One("cycles_floored_modulo", 1.0f, 0.0f));
|
||||
}
|
||||
|
||||
private static float OperandOrInfinity(string token) => token == "inf" ? float.PositiveInfinity : token == "-inf" ? float.NegativeInfinity : Float(token);
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void InfiniteOperandsFollowBlender(string target)
|
||||
{
|
||||
// infinite.json: operands that are infinite, made inside Blender's kernel (the film writes 0 for an infinity), and
|
||||
// results read back through comparisons with 0 as "inf", "-inf" or "nan" where the film showed 0.
|
||||
var functions = Load("infinite")["functions"]!.AsArray();
|
||||
Assert.Equal(new[] { "cycles_power", "cycles_modulo", "cycles_map_range_smoothstep" }, functions.Select(f => (string)f!["function"]!).ToArray());
|
||||
var cases = 0;
|
||||
foreach (var entry in functions)
|
||||
{
|
||||
var name = (string)entry!["function"]!;
|
||||
foreach (var line in entry["cases"]!.AsArray())
|
||||
{
|
||||
var sides = ((string)line!).Split(" -> ");
|
||||
var operands = sides[0].Split(' ').Select(OperandOrInfinity).Cast<object>().ToArray();
|
||||
var have = (float)Call(target, name, operands);
|
||||
var ok = sides[1] switch
|
||||
{
|
||||
"nan" => float.IsNaN(have),
|
||||
"inf" => float.IsPositiveInfinity(have),
|
||||
"-inf" => float.IsNegativeInfinity(have),
|
||||
var token => Distance(Float(token), have) == 0,
|
||||
};
|
||||
Assert.True(ok, $"{target} {name} {sides[0]}: Blender {sides[1]}, here {have}");
|
||||
cases++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(cases >= 45, $"{cases} infinite-operand cases");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void InfiniteOperandsFollowTheCyclesSource(string target)
|
||||
{
|
||||
// Blender 5.2.2 (infinite.json rows named beside each pin). powf(a, +-inf) is never negative, so an infinite
|
||||
// exponent is even; modulo and smoothstep of an infinity are NaN where inf - inf or inf / inf forms.
|
||||
float One(string function, params float[] arguments) => (float)Call(target, function, arguments.Cast<object>().ToArray());
|
||||
const float inf = float.PositiveInfinity;
|
||||
Assert.Equal(0x3f800000, BitConverter.SingleToInt32Bits(One("cycles_power", -1.0f, inf)));
|
||||
Assert.Equal(0x3f800000, BitConverter.SingleToInt32Bits(One("cycles_power", -1.0f, -inf)));
|
||||
Assert.Equal(inf, One("cycles_power", -2.0f, inf));
|
||||
Assert.Equal(inf, One("cycles_power", -0.5f, -inf));
|
||||
Assert.Equal(0, BitConverter.SingleToInt32Bits(One("cycles_power", -2.0f, -inf)));
|
||||
Assert.Equal(-8.0f, One("cycles_power", -2.0f, 3.0f));
|
||||
Assert.Equal(0x3f800000, BitConverter.SingleToInt32Bits(One("cycles_power", -1.0f, 2.0f)));
|
||||
Assert.True(float.IsNaN(One("cycles_modulo", inf, 1.0f)));
|
||||
Assert.True(float.IsNaN(One("cycles_modulo", -inf, 3.0f)));
|
||||
Assert.True(float.IsNaN(One("cycles_modulo", inf, 0.1f)));
|
||||
Assert.True(float.IsNaN(One("cycles_map_range_smoothstep", -inf, -inf, 1.0f, 2.0f, 3.0f)));
|
||||
Assert.True(float.IsNaN(One("cycles_map_range_smoothstep", -inf, 1.0f, -inf, 2.0f, 3.0f)));
|
||||
Assert.Equal(2.0f, One("cycles_map_range_smoothstep", -inf, 1.0f, 5.0f, 2.0f, 3.0f));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void InverseTrigonometryFollowsTheCyclesGuards(string target)
|
||||
{
|
||||
// util/math_base.h: safe_asinf and safe_acosf clamp to [-1, 1] before asinf and acosf; compatible_atan2 returns 0
|
||||
// at the origin (x == 0 && y == 0) and is atan2f everywhere else, so its guard is only the origin.
|
||||
float One(string function, params float[] arguments) => (float)Call(target, function, arguments.Cast<object>().ToArray());
|
||||
Assert.Equal(MathF.Asin(1.0f), One("cycles_arcsine", 2.0f));
|
||||
Assert.Equal(MathF.Asin(-1.0f), One("cycles_arcsine", -1.5f));
|
||||
Assert.Equal(MathF.Asin(1.0f), One("cycles_arcsine", 1.0000001f));
|
||||
Assert.Equal(0.0f, One("cycles_arccosine", 2.0f));
|
||||
Assert.Equal(MathF.Acos(-1.0f), One("cycles_arccosine", -2.0f));
|
||||
Assert.Equal(0.0f, One("cycles_arctan2", 0.0f, 0.0f));
|
||||
Assert.Equal(0.0f, One("cycles_arctan2", 0.0f, -0.0f));
|
||||
Assert.Equal(0.0f, One("cycles_arctan2", -0.0f, -0.0f));
|
||||
Assert.Equal(MathF.Atan2(-0.0f, -1.0f), One("cycles_arctan2", -0.0f, -1.0f));
|
||||
Assert.Equal(MathF.Atan2(0.0f, -1.0f), One("cycles_arctan2", 0.0f, -1.0f));
|
||||
Assert.Equal(MathF.Atan2(1.0f, 0.0f), One("cycles_arctan2", 1.0f, 0.0f));
|
||||
Assert.Equal(MathF.Atan2(-1.0f, 0.0f), One("cycles_arctan2", -1.0f, 0.0f));
|
||||
// The guard is the origin alone: a guard on either zero would zero these (the plain atan2 values are pi and pi/2).
|
||||
Assert.NotEqual(0.0f, One("cycles_arctan2", 0.0f, -1.0f));
|
||||
Assert.NotEqual(0.0f, One("cycles_arctan2", 1.0f, 0.0f));
|
||||
foreach (var value in new[] { One("cycles_arcsine", 2.0f), One("cycles_arccosine", -2.0f), One("cycles_arctan2", 0.0f, -0.0f), One("cycles_arctan2", 1.0f, 0.0f) })
|
||||
Assert.True(float.IsFinite(value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void MapRangeSmoothStepDecidesTheTopEdgeBeforeDividing(string target)
|
||||
{
|
||||
// util/math_base.h smoothstep returns 1 for x >= edge1 before it forms t. With an overflowing range
|
||||
// (high - low = infinity) the division would be infinity / infinity, NaN.
|
||||
float Smooth(params float[] arguments) => (float)Call(target, "cycles_map_range_smoothstep", arguments.Cast<object>().ToArray());
|
||||
Assert.Equal(1.0f, Smooth(3.0e38f, -3.0e38f, 3.0e38f, 0.0f, 1.0f));
|
||||
Assert.Equal(0.0f, Smooth(3.0e38f, 3.0e38f, -3.0e38f, 0.0f, 1.0f));
|
||||
Assert.Equal(0.0f, Smooth(-3.0e38f, -3.0e38f, 3.0e38f, 0.0f, 1.0f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlooredModuloAndWrapUnderContractionStayWithinTheBound()
|
||||
{
|
||||
// Ruling cycles-kernel-reference-ruled: a build that contracts a - floor(a / b) b into one fused operation differs
|
||||
// from the reference by at most 2^-23 (|a| + |b|), and the wrap model by 2^-23 (|value| + |min| + |range|). The fused
|
||||
// model is MathF.FusedMultiplyAdd, test-only; no cycles_* function uses it.
|
||||
var bound = Math.ScaleB(1.0, -23);
|
||||
var worstModulo = 0.0;
|
||||
var worstWrap = 0.0;
|
||||
var seenPair = false;
|
||||
foreach (var entry in Load("math")["functions"]!.AsArray())
|
||||
{
|
||||
var name = (string)entry!["function"]!;
|
||||
if (name != "cycles_floored_modulo" && name != "cycles_wrap")
|
||||
continue;
|
||||
foreach (var line in entry["cases"]!.AsArray())
|
||||
{
|
||||
var o = ((string)line!).Split(" -> ")[0].Split(' ').Select(Float).ToArray();
|
||||
if (name == "cycles_floored_modulo")
|
||||
{
|
||||
float a = o[0], b = o[1];
|
||||
if (b == 0.0f)
|
||||
continue;
|
||||
var fused = MathF.FusedMultiplyAdd(-MathF.Floor(a / b), b, a);
|
||||
var scale = Math.Abs((double)a) + Math.Abs((double)b);
|
||||
var difference = Math.Abs((double)fused - math.cycles_floored_modulo(a, b));
|
||||
Assert.True(difference <= bound * scale, $"floored modulo ({line}): fused {fused} is {difference / scale} of |a| + |b| from the reference");
|
||||
worstModulo = Math.Max(worstModulo, difference / scale);
|
||||
if (a == 10000.0f && b == 0.1f)
|
||||
{
|
||||
seenPair = true;
|
||||
Assert.Equal(-1.4901161e-4f, fused);
|
||||
Assert.Equal(0.0f, math.cycles_floored_modulo(a, b));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
float value = o[0], max = o[1], min = o[2];
|
||||
var range = max - min;
|
||||
if (range == 0.0f)
|
||||
continue;
|
||||
var fused = MathF.FusedMultiplyAdd(-range, MathF.Floor((value - min) / range), value);
|
||||
var scale = Math.Abs((double)value) + Math.Abs((double)min) + Math.Abs((double)range);
|
||||
var difference = Math.Abs((double)fused - math.cycles_wrap(value, max, min));
|
||||
Assert.True(difference <= bound * scale, $"wrap ({line}): fused {fused} is {difference / scale} of |value| + |min| + |range| from the reference");
|
||||
worstWrap = Math.Max(worstWrap, difference / scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(seenPair, "the fixture holds no (10000, 0.1) floored modulo case");
|
||||
// The cases reach the contraction regime: a bound of 2^-25 would fail.
|
||||
Assert.True(worstModulo > Math.ScaleB(1.0, -25), $"the worst floored modulo contraction is only {worstModulo} of |a| + |b|");
|
||||
Assert.True(worstWrap > Math.ScaleB(1.0, -25), $"the worst wrap contraction is only {worstWrap} of |value| + |min| + |range|");
|
||||
}
|
||||
|
||||
// The values oracle.py draws the specials of a function from (SPECIAL, plus the arcsine and arccosine edge rows); a row with any other operand holds a random draw.
|
||||
private static readonly HashSet<float> Alphabet = new[]
|
||||
{
|
||||
0.0, 1.0, -1.0, 0.5, -0.5, 2.0, -2.0, 3.0, -2.75, 0.1, -0.1, 7.5, -7.5, 1.0e-7, -1.0e-7, 100.0, -100.0, 1.0e4, 0.3, 0.7, 1.5, 1.0000001, -1.0000001,
|
||||
}.Select(v => (float)v).ToHashSet();
|
||||
|
||||
[Fact]
|
||||
public void OperandStreamsDifferPerFunction()
|
||||
{
|
||||
// oracle.py seeds every function, every blend mode's second colours and every ramp from its own name, so no two
|
||||
// share a stream of draws. At e48f9488 the seed was the name's first four bytes, and 'cycles_' shared them all.
|
||||
var functions = Load("math")["functions"]!.AsArray()
|
||||
.Select(e => (Name: (string)e!["function"]!, Rows: e["cases"]!.AsArray().Select(l => ((string)l!).Split(" -> ")[0]).ToArray()))
|
||||
.ToArray();
|
||||
Assert.True(functions.Length >= 17, "the math fixture lost functions");
|
||||
foreach (var f in functions)
|
||||
{
|
||||
var draws = f.Rows.Where(row => row.Split(' ').Any(token => !Alphabet.Contains(Float(token)))).ToHashSet();
|
||||
Assert.True(draws.Count >= 8, $"{f.Name} holds only {draws.Count} random-draw rows");
|
||||
foreach (var g in functions.Where(g => string.CompareOrdinal(f.Name, g.Name) < 0 && g.Rows[0].Split(' ').Length == f.Rows[0].Split(' ').Length))
|
||||
{
|
||||
var shared = g.Rows.Count(row => draws.Contains(row));
|
||||
Assert.True(shared == 0, $"{f.Name} and {g.Name} share {shared} random-draw rows");
|
||||
}
|
||||
}
|
||||
|
||||
var modes = Load("mix")["functions"]!.AsArray()
|
||||
.Where(e => ((string)e!["function"]!).StartsWith("cycles_mix_") && ((string)e["cases"]![0]!).Split(" -> ")[0].Split(' ').Length == 7)
|
||||
.Select(e => (Name: (string)e!["function"]!, Rows: e["cases"]!.AsArray().Select(l => ((string)l!).Split(" -> ")[0].Split(' ')).ToArray()))
|
||||
.ToArray();
|
||||
Assert.True(modes.Length >= 15, "the mix fixture lost blend modes");
|
||||
static string Multiset(IEnumerable<string[]> rows, int first) => string.Join("|", rows.Select(r => string.Join(" ", r.Skip(first).Take(3))).OrderBy(x => x, StringComparer.Ordinal));
|
||||
foreach (var mode in modes)
|
||||
Assert.True(Multiset(mode.Rows, 1) != Multiset(mode.Rows, 4), $"{mode.Name}: the second colours are the first colours");
|
||||
foreach (var f in modes)
|
||||
foreach (var g in modes.Where(g => string.CompareOrdinal(f.Name, g.Name) < 0))
|
||||
Assert.True(Multiset(f.Rows, 4) != Multiset(g.Rows, 4), $"{f.Name} and {g.Name} draw the same second colours");
|
||||
|
||||
var ramps = Load("ramp")["functions"]!.AsArray()
|
||||
.Select(e => e!["cases"]!.AsArray().Select(l => ((string)l!).Split(" -> ")[0]).ToArray()).ToArray();
|
||||
Assert.Equal(2, ramps.Length);
|
||||
var tail = ramps[0].TakeLast(100).ToHashSet();
|
||||
Assert.True(!ramps[1].TakeLast(100).Any(tail.Contains), "the ramps' random t tails overlap");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void HueSaturationAndValueOfThePrimariesFollowCycles(string target)
|
||||
{
|
||||
// util/color.h rgb_to_hsv: hue 0 for red, 1/3 for green, 2/3 for blue, saturation 1, value 1; a grey has
|
||||
// hue 0 and saturation 0. Red's hue is exactly 0 and not 1, which hsv_to_rgb would read the same.
|
||||
float[] Hsv(float r, float g, float b) => Flatten(Call(target, "cycles_rgb_to_hsv", new float3(r, g, b)));
|
||||
Assert.Equal(new[] { 0.0f, 1.0f, 1.0f }, Hsv(1.0f, 0.0f, 0.0f));
|
||||
Assert.Equal(new[] { 0.0f, 0.0f, 0.5f }, Hsv(0.5f, 0.5f, 0.5f));
|
||||
Assert.Equal(1.0f / 3.0f, Hsv(0.0f, 1.0f, 0.0f)[0], 6);
|
||||
Assert.Equal(2.0f / 3.0f, Hsv(0.0f, 0.0f, 1.0f)[0], 6);
|
||||
var back = Flatten(Call(target, "cycles_hsv_to_rgb", new float3(0.0f, 1.0f, 1.0f)));
|
||||
Assert.Equal(new[] { 1.0f, 0.0f, 0.0f }, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RgbToBwCoefficientsAreTheOnesBlenderReads()
|
||||
{
|
||||
var entry = Load("rgb-to-bw")["functions"]!.AsArray().Single()!["cases"]!.AsArray().Select(line => ((string)line!).Split(" -> ")).ToArray();
|
||||
float Primary(int channel) => Float(entry.First(c =>
|
||||
{
|
||||
var operands = c[0].Split(' ').Select(Float).ToArray();
|
||||
return Enumerable.Range(0, 3).All(k => operands[k] == (k == channel ? 1.0f : 0.0f));
|
||||
})[1]);
|
||||
Assert.Equal(math.CYCLES_RGB_TO_Y_R, Primary(0));
|
||||
Assert.Equal(math.CYCLES_RGB_TO_Y_G, Primary(1));
|
||||
Assert.Equal(math.CYCLES_RGB_TO_Y_B, Primary(2));
|
||||
}
|
||||
}
|
||||
611
packages/cultmath/tests/CultMath.Tests/ExprTests.cs
Normal file
611
packages/cultmath/tests/CultMath.Tests/ExprTests.cs
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
using CultMath.Expr;
|
||||
using Xunit;
|
||||
using static CultMath.math;
|
||||
|
||||
namespace CultMath.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// CultMath.Expr: the evaluator equals the direct composition of the same math calls bit for bit (every op and a
|
||||
/// corpus imitating Aetheria's trophic compositions), the builder is canonical and refuses at the call, substitution
|
||||
/// builds stencils, constants and param defaults keep their bits, bindings resolve by name, the validator's rules
|
||||
/// refuse each one by name, and no refusal echoes a value it was given.
|
||||
///
|
||||
/// Hand mutations these must kill: an op's implementation or operand order swapped in the table (the per-op
|
||||
/// comparison); hash-consing disabled or the dead-node pass removed (BuildIsCanonical); Substitute leaving an input
|
||||
/// unmapped (SubstituteEqualsOffsetEvaluation); a validator rule deleted (ValidatorRefusesEachRuleByName and the
|
||||
/// builder refusals); a constant routed through float arithmetic (ConstantsKeepTheirBits).
|
||||
/// </summary>
|
||||
public sealed class ExprTests
|
||||
{
|
||||
private const int Points = 4096;
|
||||
private const string Canary = "CANARY";
|
||||
|
||||
private static readonly float[] Edge =
|
||||
{
|
||||
0f, -0f, 1f, -1f, 0.5f, 3f, 1e-30f, float.Epsilon, -float.Epsilon, 1.17549435e-38f / 2f,
|
||||
float.NaN, float.PositiveInfinity, float.NegativeInfinity, float.MaxValue, -float.MaxValue, 1e30f, -1e30f,
|
||||
};
|
||||
|
||||
private static bool Same(float a, float b) =>
|
||||
(float.IsNaN(a) && float.IsNaN(b)) || BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b);
|
||||
|
||||
private static float[] Column(System.Random rng, int points, float lo, float hi, double edgeShare)
|
||||
{
|
||||
var column = new float[points];
|
||||
for (var i = 0; i < points; i++)
|
||||
column[i] = rng.NextDouble() < edgeShare ? Edge[rng.Next(Edge.Length)] : lo + (hi - lo) * rng.NextSingle();
|
||||
return column;
|
||||
}
|
||||
|
||||
private static float[] Pack(params float[][] columns) => columns.SelectMany(column => column).ToArray();
|
||||
|
||||
private static float[] Run(ExprGraph graph, ExprBindings bindings, params float[][] columns)
|
||||
{
|
||||
var points = columns[0].Length;
|
||||
var outputs = new float[points * graph.OutputNames.Count];
|
||||
ExprEvaluator.Evaluate(graph, bindings, Pack(columns), outputs);
|
||||
return outputs;
|
||||
}
|
||||
|
||||
private static void AssertSame(float[] expected, float[] actual, int offset, string what)
|
||||
{
|
||||
for (var i = 0; i < expected.Length; i++)
|
||||
Assert.True(Same(expected[i], actual[offset + i]), $"{what} point {i}: expected bits {BitConverter.SingleToInt32Bits(expected[i]):X8}, got {BitConverter.SingleToInt32Bits(actual[offset + i]):X8}");
|
||||
}
|
||||
|
||||
// The reference side is written by hand, beside the table and not from it.
|
||||
private static readonly Dictionary<string, Func<float[], float[]>> Reference = new()
|
||||
{
|
||||
["add"] = a => new[] { a[0] + a[1] },
|
||||
["sub"] = a => new[] { a[0] - a[1] },
|
||||
["mul"] = a => new[] { a[0] * a[1] },
|
||||
["div"] = a => new[] { a[0] / a[1] },
|
||||
["neg"] = a => new[] { -a[0] },
|
||||
["min"] = a => new[] { min(a[0], a[1]) },
|
||||
["max"] = a => new[] { max(a[0], a[1]) },
|
||||
["abs"] = a => new[] { abs(a[0]) },
|
||||
["floor"] = a => new[] { floor(a[0]) },
|
||||
["frac"] = a => new[] { frac(a[0]) },
|
||||
["sqrt"] = a => new[] { sqrt(a[0]) },
|
||||
["exp"] = a => new[] { exp(a[0]) },
|
||||
["log"] = a => new[] { log(a[0]) },
|
||||
["pow"] = a => new[] { pow(a[0], a[1]) },
|
||||
["sin"] = a => new[] { sin(a[0]) },
|
||||
["cos"] = a => new[] { cos(a[0]) },
|
||||
["clamp"] = a => new[] { clamp(a[0], a[1], a[2]) },
|
||||
["saturate"] = a => new[] { saturate(a[0]) },
|
||||
["lerp"] = a => new[] { lerp(a[0], a[1], a[2]) },
|
||||
["step"] = a => new[] { step(a[0], a[1]) },
|
||||
["smoothstep"] = a => new[] { smoothstep(a[0], a[1], a[2]) },
|
||||
["select"] = a => new[] { a[0] > a[1] ? a[2] : a[3] },
|
||||
["snoise2"] = a => new[] { snoise(new float2(a[0], a[1])) },
|
||||
["snoise3"] = a => new[] { snoise(new float3(a[0], a[1], a[2])) },
|
||||
["cellular3"] = a =>
|
||||
{
|
||||
var c = cellular(new float3(a[0], a[1], a[2]));
|
||||
return new[] { c.nearest.x, c.nearest.y, c.nearest.z, c.nearest.w, c.edge.x, c.edge.y, c.edge.z, c.edge.w, c.id };
|
||||
},
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void OpTableIsTheStableVocabulary()
|
||||
{
|
||||
var expected = new (string Name, int Arity, int Results)[]
|
||||
{
|
||||
("add", 2, 1), ("sub", 2, 1), ("mul", 2, 1), ("div", 2, 1), ("neg", 1, 1), ("min", 2, 1), ("max", 2, 1),
|
||||
("abs", 1, 1), ("floor", 1, 1), ("frac", 1, 1), ("sqrt", 1, 1), ("exp", 1, 1), ("log", 1, 1), ("pow", 2, 1),
|
||||
("sin", 1, 1), ("cos", 1, 1), ("clamp", 3, 1), ("saturate", 1, 1), ("lerp", 3, 1), ("step", 2, 1),
|
||||
("smoothstep", 3, 1), ("select", 4, 1), ("snoise2", 2, 1), ("snoise3", 3, 1), ("cellular3", 3, 9),
|
||||
};
|
||||
Assert.Equal(expected, ExprOps.All.Select(op => (op.Name, op.Arity, op.ResultCount)).ToArray());
|
||||
Assert.Equal(ExprOps.All.Count, Reference.Count);
|
||||
Assert.Equal(expected.Select(op => op.Name).OrderBy(name => name), Reference.Keys.OrderBy(name => name));
|
||||
Assert.Null(ExprOps.Find("input"));
|
||||
Assert.Null(ExprOps.Find("nope"));
|
||||
Assert.Same(ExprOps.All[3], ExprOps.Find("div"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluatorEqualsDirectComposition()
|
||||
{
|
||||
var rng = new System.Random(20261014);
|
||||
foreach (var op in ExprOps.All)
|
||||
{
|
||||
var builder = new ExprBuilder();
|
||||
var inputs = Enumerable.Range(0, op.Arity).Select(i => builder.Input("i" + i)).ToArray();
|
||||
var call = builder.Call(op.Name, inputs);
|
||||
if (op.ResultCount == 1) builder.Output("r", call);
|
||||
else for (var k = 0; k < op.ResultCount; k++) builder.Output("r" + k, builder.Pick(call, k));
|
||||
var graph = builder.Build();
|
||||
|
||||
var columns = Enumerable.Range(0, op.Arity).Select(_ => Column(rng, Points, -8f, 8f, 0.25)).ToArray();
|
||||
var outputs = Run(graph, ExprBindings.Bind(graph), columns);
|
||||
for (var k = 0; k < op.ResultCount; k++)
|
||||
{
|
||||
var expected = new float[Points];
|
||||
for (var p = 0; p < Points; p++)
|
||||
expected[p] = Reference[op.Name](columns.Select(column => column[p]).ToArray())[k];
|
||||
AssertSame(expected, outputs, k * Points, op.Name + " result " + k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluatorEqualsDirectCompositionOnTheCorpus()
|
||||
{
|
||||
var rng = new System.Random(77);
|
||||
var x = Column(rng, Points, -2f, 3f, 0.2);
|
||||
var z = Column(rng, Points, -500f, 500f, 0.1);
|
||||
|
||||
// smooth: smoothstep(0.2, 0.8, v)
|
||||
var b = new ExprBuilder();
|
||||
b.Output("o", b.Call("smoothstep", b.Const(0.2f), b.Const(0.8f), b.Input("v")));
|
||||
var smooth = b.Build();
|
||||
AssertSame(x.Select(v => smoothstep(0.2f, 0.8f, v)).ToArray(), Run(smooth, ExprBindings.Bind(smooth), x), 0, "smooth");
|
||||
|
||||
// holling: p * (1 + k) / (p + k), k a param
|
||||
b = new ExprBuilder();
|
||||
var p = b.Input("p");
|
||||
var k = b.Param("k", 0.25f);
|
||||
b.Output("o", b.Call("div", b.Call("mul", p, b.Call("add", b.Const(1f), k)), b.Call("add", p, k)));
|
||||
var holling = b.Build();
|
||||
AssertSame(x.Select(v => v * (1f + 0.25f) / (v + 0.25f)).ToArray(), Run(holling, ExprBindings.Bind(holling), x), 0, "holling");
|
||||
var overrides = new Dictionary<string, float> { ["k"] = 0.4f };
|
||||
AssertSame(x.Select(v => v * (1f + 0.4f) / (v + 0.4f)).ToArray(), Run(holling, ExprBindings.Bind(holling, overrides), x), 0, "holling k=0.4");
|
||||
|
||||
// envelope: b^0.75
|
||||
b = new ExprBuilder();
|
||||
b.Output("o", b.Call("pow", b.Input("b"), b.Const(0.75f)));
|
||||
var envelope = b.Build();
|
||||
AssertSame(x.Select(v => pow(v, 0.75f)).ToArray(), Run(envelope, ExprBindings.Bind(envelope), x), 0, "envelope");
|
||||
|
||||
// an 8-tap ring stencil of snoise2, with the centre, summed in order
|
||||
var (ring, offsets) = RingStencil(48f);
|
||||
var expected = new float[Points];
|
||||
for (var i = 0; i < Points; i++)
|
||||
{
|
||||
var sum = snoise(new float2(x[i] * 0.013f, z[i] * 0.017f));
|
||||
for (var t = 0; t < 8; t++) sum += snoise(new float2((x[i] + offsets[t].x) * 0.013f, (z[i] + offsets[t].y) * 0.017f));
|
||||
expected[i] = sum / 9f;
|
||||
}
|
||||
|
||||
AssertSame(expected, Run(ring, ExprBindings.Bind(ring), x, z), 0, "ring stencil");
|
||||
|
||||
// a cellular3 Pick chain: (edge.w - nearest.w) * id
|
||||
b = new ExprBuilder();
|
||||
var cell = b.Call("cellular3", b.Input("x"), b.Input("y"), b.Input("z"));
|
||||
b.Output("o", b.Call("mul", b.Call("sub", b.Pick(cell, 7), b.Pick(cell, 3)), b.Pick(cell, 8)));
|
||||
var chain = b.Build();
|
||||
var y = Column(rng, Points, -6f, 6f, 0.05);
|
||||
var cx = Column(rng, Points, -6f, 6f, 0.05);
|
||||
var cz = Column(rng, Points, -6f, 6f, 0.05);
|
||||
AssertSame(
|
||||
Enumerable.Range(0, Points).Select(i =>
|
||||
{
|
||||
var c = cellular(new float3(cx[i], y[i], cz[i]));
|
||||
return (c.edge.w - c.nearest.w) * c.id;
|
||||
}).ToArray(),
|
||||
Run(chain, ExprBindings.Bind(chain), cx, y, cz), 0, "cellular chain");
|
||||
}
|
||||
|
||||
private static ExprGraph Field()
|
||||
{
|
||||
var b = new ExprBuilder();
|
||||
b.Output("f", b.Call("snoise2", b.Call("mul", b.Input("x"), b.Const(0.013f)), b.Call("mul", b.Input("z"), b.Const(0.017f))));
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
private static (ExprGraph Graph, (float x, float y)[] Offsets) RingStencil(float radius)
|
||||
{
|
||||
var field = Field();
|
||||
var offsets = Enumerable.Range(0, 8)
|
||||
.Select(t => (MathF.Cos(2f * MathF.PI * t / 8f) * radius, MathF.Sin(2f * MathF.PI * t / 8f) * radius))
|
||||
.ToArray();
|
||||
var b = new ExprBuilder();
|
||||
var x = b.Input("x");
|
||||
var z = b.Input("z");
|
||||
var sum = b.Substitute(field, new Dictionary<string, ExprValue> { ["x"] = x, ["z"] = z })["f"];
|
||||
foreach (var (ox, oz) in offsets)
|
||||
{
|
||||
var tap = b.Substitute(field, new Dictionary<string, ExprValue>
|
||||
{
|
||||
["x"] = b.Call("add", x, b.Const(ox)),
|
||||
["z"] = b.Call("add", z, b.Const(oz)),
|
||||
})["f"];
|
||||
sum = b.Call("add", sum, tap);
|
||||
}
|
||||
|
||||
b.Output("o", b.Call("div", sum, b.Const(9f)));
|
||||
return (b.Build(), offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEqualsOffsetEvaluation()
|
||||
{
|
||||
var rng = new System.Random(5);
|
||||
var x = Column(rng, Points, -500f, 500f, 0.05);
|
||||
var z = Column(rng, Points, -500f, 500f, 0.05);
|
||||
var field = Field();
|
||||
var (stencil, offsets) = RingStencil(48f);
|
||||
|
||||
var centre = Run(field, ExprBindings.Bind(field), x, z);
|
||||
var sum = (float[])centre.Clone();
|
||||
for (var t = 0; t < 8; t++)
|
||||
{
|
||||
var tap = Run(field, ExprBindings.Bind(field), x.Select(v => v + offsets[t].x).ToArray(), z.Select(v => v + offsets[t].y).ToArray());
|
||||
for (var i = 0; i < Points; i++) sum[i] += tap[i];
|
||||
}
|
||||
|
||||
var expected = sum.Select(s => s / 9f).ToArray();
|
||||
AssertSame(expected, Run(stencil, ExprBindings.Bind(stencil), x, z), 0, "substituted stencil");
|
||||
|
||||
// The wrong input substituted (z only) is a different graph and differs.
|
||||
var b = new ExprBuilder();
|
||||
b.Input("x");
|
||||
var wz = b.Input("z");
|
||||
var wrong = b.Substitute(field, new Dictionary<string, ExprValue> { ["z"] = b.Call("add", wz, b.Const(offsets[1].y)) })["f"];
|
||||
b.Output("o", wrong);
|
||||
var wrongGraph = b.Build();
|
||||
var wrongOut = Run(wrongGraph, ExprBindings.Bind(wrongGraph), x, z);
|
||||
var offsetOut = Run(field, ExprBindings.Bind(field), x, z.Select(v => v + offsets[1].y).ToArray());
|
||||
AssertSame(offsetOut, wrongOut, 0, "z-only substitution equals z-only offset");
|
||||
var xOffset = Run(field, ExprBindings.Bind(field), x.Select(v => v + offsets[1].x).ToArray(), z.Select(v => v + offsets[1].y).ToArray());
|
||||
Assert.Contains(Enumerable.Range(0, Points), i => !Same(xOffset[i], wrongOut[i]));
|
||||
|
||||
// Substitute declares the graph's ports it does not replace, and refuses a name the graph lacks.
|
||||
Assert.Equal(new[] { "x", "z" }, wrongGraph.InputNames);
|
||||
var half = new ExprBuilder();
|
||||
half.Output("f", half.Substitute(field, new Dictionary<string, ExprValue> { ["z"] = half.Const(1f) })["f"]);
|
||||
Assert.Equal(new[] { "x" }, half.Build().InputNames);
|
||||
var c = new ExprBuilder();
|
||||
var ex = Assert.Throws<ExprGraphException>(() => c.Substitute(field, new Dictionary<string, ExprValue> { [Canary] = c.Const(1f) }));
|
||||
Assert.Equal("unknown-input", ex.Rule);
|
||||
Assert.DoesNotContain(Canary, ex.Message);
|
||||
var d = new ExprBuilder();
|
||||
var foreign = Assert.Throws<ExprGraphException>(() => d.Substitute(field, new Dictionary<string, ExprValue> { ["x"] = c.Const(1f) }));
|
||||
Assert.Equal("foreign-operand", foreign.Rule);
|
||||
var cell = d.Call("cellular3", d.Const(0f), d.Const(0f), d.Const(0f));
|
||||
Assert.Equal("bare-multi-result", Assert.Throws<ExprGraphException>(() => d.Substitute(field, new Dictionary<string, ExprValue> { ["x"] = cell })).Rule);
|
||||
|
||||
// The checks are the call's own: a graph that is only its input still refuses a foreign or multi-result replacement.
|
||||
var identity = new ExprBuilder();
|
||||
identity.Output("o", identity.Input("x"));
|
||||
var passThrough = identity.Build();
|
||||
var e = new ExprBuilder();
|
||||
Assert.Equal("foreign-operand", Refusal(() => e.Substitute(passThrough, new Dictionary<string, ExprValue> { ["x"] = c.Const(1f) })).Rule);
|
||||
Assert.Equal("bare-multi-result", Refusal(() => e.Substitute(passThrough, new Dictionary<string, ExprValue> { ["x"] = e.Call("cellular3", e.Const(0f), e.Const(0f), e.Const(0f)) })).Rule);
|
||||
Assert.Same(c.Const(1f), c.Substitute(passThrough, new Dictionary<string, ExprValue> { ["x"] = c.Const(1f) })["o"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteCarriesParamsConstantsAndPicks()
|
||||
{
|
||||
// o = p * k + cellular(p, p, k).edge.w - 0.5, with k a param of default 0.25.
|
||||
var g = new ExprBuilder();
|
||||
var gp = g.Input("p");
|
||||
var gk = g.Param("k", 0.25f);
|
||||
var cell = g.Call("cellular3", gp, gp, gk);
|
||||
g.Output("o", g.Call("sub", g.Call("add", g.Call("mul", gp, gk), g.Pick(cell, 7)), g.Const(0.5f)));
|
||||
var graph = g.Build();
|
||||
|
||||
var b = new ExprBuilder();
|
||||
var shifted = b.Call("add", b.Input("x"), b.Const(1.5f));
|
||||
b.Output("o", b.Substitute(graph, new Dictionary<string, ExprValue> { ["p"] = shifted })["o"]);
|
||||
var rebuilt = b.Build();
|
||||
|
||||
Assert.Equal(new[] { "x" }, rebuilt.InputNames);
|
||||
Assert.Equal(graph.ParamNames, rebuilt.ParamNames);
|
||||
Assert.Equal(graph.ParamDefaultBits, rebuilt.ParamDefaultBits);
|
||||
var x = Column(new System.Random(3), 512, -5f, 5f, 0.1);
|
||||
foreach (var k in new[] { 0.25f, 0.9f })
|
||||
{
|
||||
var expected = x.Select(v =>
|
||||
{
|
||||
var p = v + 1.5f;
|
||||
return p * k + cellular(new float3(p, p, k)).edge.w - 0.5f;
|
||||
}).ToArray();
|
||||
var bindings = ExprBindings.Bind(rebuilt, new Dictionary<string, float> { ["k"] = k });
|
||||
AssertSame(expected, Run(rebuilt, bindings, x), 0, "substituted graph, k=" + k);
|
||||
}
|
||||
|
||||
// A param met again with other default bits is a conflict, not a silent pick.
|
||||
var c = new ExprBuilder();
|
||||
c.Param("k", 0.5f);
|
||||
Assert.Equal("port-conflict", Refusal(() => c.Substitute(graph, new Dictionary<string, ExprValue>())).Rule);
|
||||
}
|
||||
|
||||
private static string Signature(ExprGraph g) =>
|
||||
string.Join(";", g.InputNames) + "|" + string.Join(";", g.ParamNames.Zip(g.ParamDefaultBits, (n, v) => n + "=" + v)) + "|" +
|
||||
string.Join(";", g.OutputNames.Zip(g.OutputNodes, (n, v) => n + "=" + v)) + "|" +
|
||||
string.Join(";", g.Nodes.Select(n => n.WireName + "(" + string.Join(",", n.Operands) + ")" + n.Payload));
|
||||
|
||||
private static ExprGraph Sample(bool withDeadNode)
|
||||
{
|
||||
var b = new ExprBuilder();
|
||||
var x = b.Input("x");
|
||||
var y = b.Input("y");
|
||||
b.Param("unused", 2f);
|
||||
var once = b.Call("mul", x, y);
|
||||
var twice = b.Call("mul", x, y);
|
||||
if (withDeadNode) b.Call("sin", b.Call("neg", y));
|
||||
b.Output("o", b.Call("add", once, twice));
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildIsCanonical()
|
||||
{
|
||||
var graph = Sample(false);
|
||||
Assert.Equal(Signature(graph), Signature(Sample(false)));
|
||||
|
||||
Assert.Single(graph.Nodes, n => n.WireName == "mul");
|
||||
Assert.Equal(new[] { "input", "input", "mul", "add" }, graph.Nodes.Select(n => n.WireName));
|
||||
Assert.Equal(new[] { 2, 2 }, graph.Nodes[3].Operands);
|
||||
|
||||
var withDead = Sample(true);
|
||||
Assert.Equal(Signature(graph), Signature(withDead));
|
||||
Assert.DoesNotContain(withDead.Nodes, n => n.WireName == "sin" || n.WireName == "neg");
|
||||
|
||||
// The ports are the graph's contract: an unused param stays, and constants are told apart by bits.
|
||||
Assert.Equal(new[] { "unused" }, graph.ParamNames);
|
||||
var b = new ExprBuilder();
|
||||
var sameZero = b.Call("add", b.Const(0f), b.Const(0f));
|
||||
var minusZero = b.Const(-0f);
|
||||
b.Output("a", sameZero);
|
||||
b.Output("b", minusZero);
|
||||
var zeros = b.Build();
|
||||
Assert.Equal(new[] { 0, int.MinValue }, zeros.Nodes.Where(n => n.Kind == ExprNodeKind.Const).Select(n => n.Payload));
|
||||
Assert.Equal(3, zeros.Nodes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstantsKeepTheirBits()
|
||||
{
|
||||
var nanBits = 0x7FC12345;
|
||||
var constants = new[] { -0f, BitConverter.Int32BitsToSingle(nanBits), BitConverter.Int32BitsToSingle(1) };
|
||||
var b = new ExprBuilder();
|
||||
for (var i = 0; i < constants.Length; i++) b.Output("c" + i, b.Const(constants[i]));
|
||||
b.Output("p", b.Param("p", BitConverter.Int32BitsToSingle(nanBits)));
|
||||
var graph = b.Build();
|
||||
|
||||
Assert.Equal(new[] { unchecked((int)0x80000000), nanBits, 1 }, graph.Nodes.Where(n => n.Kind == ExprNodeKind.Const).Select(n => n.Payload));
|
||||
Assert.Equal(new[] { nanBits }, graph.ParamDefaultBits);
|
||||
var outputs = new float[graph.OutputNames.Count];
|
||||
ExprEvaluator.Evaluate(graph, ExprBindings.Bind(graph), ReadOnlySpan<float>.Empty, outputs);
|
||||
Assert.Equal(new[] { unchecked((int)0x80000000), nanBits, 1, nanBits }, outputs.Select(BitConverter.SingleToInt32Bits));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindingsResolveByName()
|
||||
{
|
||||
var b = new ExprBuilder();
|
||||
b.Output("o", b.Call("sub", b.Param("a", 1.5f), b.Param("b", 4f)));
|
||||
var graph = b.Build();
|
||||
float Eval(ExprBindings bindings)
|
||||
{
|
||||
var o = new float[1];
|
||||
ExprEvaluator.Evaluate(graph, bindings, ReadOnlySpan<float>.Empty, o);
|
||||
return o[0];
|
||||
}
|
||||
|
||||
Assert.Equal(1.5f - 4f, Eval(ExprBindings.Bind(graph)));
|
||||
Assert.Equal(1.5f - 4f, Eval(ExprBindings.Bind(graph, new Dictionary<string, float>())));
|
||||
Assert.Equal(10f - 4f, Eval(ExprBindings.Bind(graph, new Dictionary<string, float> { ["a"] = 10f })));
|
||||
Assert.Equal(1.5f - 7f, Eval(ExprBindings.Bind(graph, new Dictionary<string, float> { ["b"] = 7f })));
|
||||
|
||||
var ex = Assert.Throws<ExprGraphException>(() => ExprBindings.Bind(graph, new Dictionary<string, float> { [Canary] = 1f }));
|
||||
Assert.Equal("unknown-param", ex.Rule);
|
||||
Assert.DoesNotContain(Canary, ex.Message);
|
||||
|
||||
var other = new ExprBuilder();
|
||||
other.Output("o", other.Const(1f));
|
||||
var foreign = other.Build();
|
||||
Assert.Equal("foreign-bindings", Assert.Throws<ExprGraphException>(() =>
|
||||
ExprEvaluator.Evaluate(graph, ExprBindings.Bind(foreign), ReadOnlySpan<float>.Empty, new float[1])).Rule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateRefusesWrongColumnShapes()
|
||||
{
|
||||
var b = new ExprBuilder();
|
||||
b.Output("o", b.Input("x"));
|
||||
b.Output("p", b.Call("neg", b.Input("x")));
|
||||
var graph = b.Build();
|
||||
var bindings = ExprBindings.Bind(graph);
|
||||
Assert.Equal("column-shape", Assert.Throws<ExprGraphException>(() => ExprEvaluator.Evaluate(graph, bindings, new float[2], new float[5])).Rule);
|
||||
Assert.Equal("column-shape", Assert.Throws<ExprGraphException>(() => ExprEvaluator.Evaluate(graph, bindings, new float[3], new float[4])).Rule);
|
||||
var short_ = new float[4];
|
||||
Assert.Equal("column-shape", Assert.Throws<ExprGraphException>(() => ExprEvaluator.Evaluate(graph, bindings, new float[1], short_)).Rule);
|
||||
Assert.Equal("column-shape", Assert.Throws<ExprGraphException>(() => ExprEvaluator.Evaluate(graph, bindings, ReadOnlySpan<float>.Empty, short_)).Rule);
|
||||
Assert.Equal(new float[4], short_);
|
||||
var outputs = new float[6];
|
||||
ExprEvaluator.Evaluate(graph, bindings, new[] { 1f, 2f, 3f }, outputs);
|
||||
var none = new ExprBuilder().Build();
|
||||
ExprEvaluator.Evaluate(none, ExprBindings.Bind(none), ReadOnlySpan<float>.Empty, Span<float>.Empty);
|
||||
Assert.Equal(new[] { 1f, 2f, 3f, -1f, -2f, -3f }, outputs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AValidatedGraphCannotBeChangedThroughItsLists()
|
||||
{
|
||||
var b = new ExprBuilder();
|
||||
var x = b.Input("x");
|
||||
var k = b.Param("k", 2f);
|
||||
b.Output("o", b.Call("mul", x, k));
|
||||
var graph = b.Build();
|
||||
var input = new[] { 1f, 2f, 3f };
|
||||
var before = new float[3];
|
||||
ExprEvaluator.Evaluate(graph, ExprBindings.Bind(graph), input, before);
|
||||
var operands = graph.Nodes[2].Operands;
|
||||
|
||||
Assert.False(graph.InputNames is string[] || graph.ParamNames is string[] || graph.OutputNames is string[]);
|
||||
Assert.False(graph.ParamDefaultBits is int[] || graph.OutputNodes is int[] || operands is int[] || graph.Nodes is ExprNode[]);
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<int>)operands)[0] = 7);
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<int>)graph.OutputNodes)[0] = 99);
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<int>)graph.ParamDefaultBits)[0] = 0);
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<string>)graph.InputNames)[0] = "bad name");
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<string>)graph.ParamNames)[0] = "bad name");
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<string>)graph.OutputNames)[0] = "bad name");
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<ExprNode>)graph.Nodes)[0] = graph.Nodes[1]);
|
||||
|
||||
var after = new float[3];
|
||||
ExprEvaluator.Evaluate(graph, ExprBindings.Bind(graph), input, after);
|
||||
Assert.Equal(before, after);
|
||||
Assert.Equal(new[] { 2f, 4f, 6f }, after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateCopiesWhatItIsGiven()
|
||||
{
|
||||
var inputs = new[] { "x" };
|
||||
var outputs = new[] { "o" };
|
||||
var outputNodes = new[] { 0 };
|
||||
var nodes = new[] { ExprNode.Input(0) };
|
||||
var graph = ExprGraph.Create(ExprGraph.FormatVersion, inputs, Array.Empty<string>(), Array.Empty<int>(), outputs, outputNodes, nodes);
|
||||
|
||||
inputs[0] = "bad name";
|
||||
outputs[0] = "bad name";
|
||||
outputNodes[0] = 99;
|
||||
nodes[0] = ExprNode.Const(0);
|
||||
|
||||
Assert.Equal(new[] { "x" }, graph.InputNames);
|
||||
Assert.Equal(new[] { "o" }, graph.OutputNames);
|
||||
Assert.Equal(new[] { 0 }, graph.OutputNodes);
|
||||
Assert.Equal(ExprNodeKind.Input, graph.Nodes[0].Kind);
|
||||
var operands = new[] { 0 };
|
||||
var call = ExprNode.Call("neg", operands);
|
||||
operands[0] = 5;
|
||||
Assert.Equal(new[] { 0 }, call.Operands);
|
||||
}
|
||||
|
||||
private static ExprGraphException Refusal(Action action) => Assert.Throws<ExprGraphException>(action);
|
||||
|
||||
[Fact]
|
||||
public void TheBuilderRefusesAtTheCall()
|
||||
{
|
||||
var b = new ExprBuilder();
|
||||
var x = b.Input("x");
|
||||
var cell = b.Call("cellular3", x, x, x);
|
||||
|
||||
var unknown = Refusal(() => b.Call(Canary + "op", x));
|
||||
Assert.Equal("unknown-op", unknown.Rule);
|
||||
Assert.Equal(2, unknown.NodeIndex);
|
||||
Assert.DoesNotContain(Canary, unknown.Message);
|
||||
Assert.Equal("arity", Refusal(() => b.Call("add", x)).Rule);
|
||||
Assert.Equal("arity", Refusal(() => b.Call("neg", x, x)).Rule);
|
||||
Assert.Equal("pick-range", Refusal(() => b.Pick(x, 0)).Rule);
|
||||
Assert.Equal("pick-range", Refusal(() => b.Pick(cell, 9)).Rule);
|
||||
Assert.Equal("pick-range", Refusal(() => b.Pick(cell, -1)).Rule);
|
||||
Assert.Equal("bare-multi-result", Refusal(() => b.Call("neg", cell)).Rule);
|
||||
Assert.Equal("bare-multi-result", Refusal(() => b.Output("o", cell)).Rule);
|
||||
Assert.Equal("foreign-operand", Refusal(() => b.Call("neg", new ExprBuilder().Const(1f))).Rule);
|
||||
Assert.Equal("port-name", Refusal(() => b.Input(Canary + "-bad")).Rule);
|
||||
Assert.Equal("port-name", Refusal(() => b.Input("1x")).Rule);
|
||||
Assert.Equal("port-name", Refusal(() => b.Input("")).Rule);
|
||||
Assert.Equal("port-name", Refusal(() => b.Param("a b", 1f)).Rule);
|
||||
Assert.Equal("port-name", Refusal(() => b.Output("a.b", x)).Rule);
|
||||
Assert.Equal(b.Pick(cell, 8).ResultCount, 1);
|
||||
Assert.Equal(9, cell.ResultCount);
|
||||
|
||||
b.Output("o", x);
|
||||
Assert.Equal("duplicate-port-name", Refusal(() => b.Output("o", x)).Rule);
|
||||
Assert.Same(x, b.Input("x"));
|
||||
b.Param("p", 1f);
|
||||
Assert.Equal("port-conflict", Refusal(() => b.Param("p", 2f)).Rule);
|
||||
Assert.Same(b.Param("p", 1f), b.Param("p", 1f));
|
||||
Assert.NotNull(b.Input("p"));
|
||||
Assert.Equal(new[] { "x", "p" }, b.Build().InputNames);
|
||||
}
|
||||
|
||||
private static ExprNode[] N(params ExprNode[] nodes) => nodes;
|
||||
|
||||
private static ExprGraph Make(
|
||||
ExprNode[] nodes,
|
||||
int version = 1,
|
||||
string[]? inputs = null,
|
||||
string[]? paramNames = null,
|
||||
int[]? paramBits = null,
|
||||
string[]? outputNames = null,
|
||||
int[]? outputNodes = null) =>
|
||||
ExprGraph.Create(
|
||||
version,
|
||||
inputs ?? new[] { "x" },
|
||||
paramNames ?? Array.Empty<string>(),
|
||||
paramBits ?? Array.Empty<int>(),
|
||||
outputNames ?? new[] { "o" },
|
||||
outputNodes ?? new[] { 0 },
|
||||
nodes);
|
||||
|
||||
[Fact]
|
||||
public void ValidatorRefusesEachRuleByName()
|
||||
{
|
||||
void Rule(string rule, Action act, int node = -1)
|
||||
{
|
||||
var ex = Assert.Throws<ExprGraphException>(act);
|
||||
Assert.Equal(rule, ex.Rule);
|
||||
Assert.Equal(node, ex.NodeIndex);
|
||||
}
|
||||
|
||||
var x = ExprNode.Input(0);
|
||||
Assert.Equal(1, Make(nodes: N(x)).Nodes.Count);
|
||||
Rule("version", () => Make(version: 2, nodes: N(x)));
|
||||
Rule("version", () => Make(version: 0, nodes: N(x)));
|
||||
Rule("port-count", () => Make(paramNames: new[] { "a" }, nodes: N(x)));
|
||||
Rule("port-count", () => Make(outputNames: new[] { "a", "b" }, nodes: N(x)));
|
||||
Rule("port-name", () => Make(inputs: new[] { "9" }, nodes: N(x)));
|
||||
Rule("duplicate-port-name", () => Make(inputs: new[] { "x", "x" }, nodes: N(x)));
|
||||
Rule("duplicate-port-name", () => Make(paramNames: new[] { "a", "a" }, paramBits: new[] { 0, 0 }, nodes: N(x)));
|
||||
Rule("duplicate-port-name", () => Make(outputNames: new[] { "a", "a" }, outputNodes: new[] { 0, 0 }, nodes: N(x)));
|
||||
Assert.Equal(1, Make(inputs: new[] { "x" }, paramNames: new[] { "x" }, paramBits: new[] { 0 }, nodes: N(x)).Nodes.Count);
|
||||
Rule("port-index", () => Make(nodes: N(ExprNode.Input(1))), 0);
|
||||
Rule("port-index", () => Make(nodes: N(ExprNode.Input(-1))), 0);
|
||||
Rule("port-index", () => Make(nodes: N(x, ExprNode.Param(0))), 1);
|
||||
Rule("arity", () => Make(nodes: N(ExprNode.FromWire("input", new[] { 0 }, 0))), 0);
|
||||
Rule("arity", () => Make(nodes: N(ExprNode.FromWire("const", new[] { 0 }, 0))), 0);
|
||||
Rule("arity", () => Make(nodes: N(x, ExprNode.FromWire("pick", new int[0], 0))), 1);
|
||||
Rule("payload", () => Make(nodes: N(x, ExprNode.FromWire("neg", new[] { 0 }, 7))), 1);
|
||||
Rule("output-node", () => Make(outputNodes: new[] { 1 }, nodes: N(x)));
|
||||
Rule("output-node", () => Make(outputNodes: new[] { -1 }, nodes: N(x)));
|
||||
Rule("bare-multi-result", () => Make(outputNodes: new[] { 1 }, nodes: N(x, ExprNode.Call("cellular3", new[] { 0, 0, 0 }))), 1);
|
||||
Rule("pick-range", () => Make(outputNodes: new[] { 1 }, nodes: N(x, ExprNode.Pick(0, 0))), 1);
|
||||
Rule("operand-order", () => Make(nodes: N(x, ExprNode.Pick(5, 0))), 1);
|
||||
Rule("operand-order", () => Make(nodes: N(x, ExprNode.Call("neg", new[] { -1 }))), 1);
|
||||
Rule("operand-order", () => Make(nodes: N(x, ExprNode.Call("neg", new[] { 1 }))), 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortNamesAcceptTheirWholeAlphabetAndNothingBeside()
|
||||
{
|
||||
foreach (var name in new[] { "a", "z", "A", "Z", "_", "_9", "a0", "Z9", "x_Y_z_0" })
|
||||
Assert.Equal(new[] { name }, Make(nodes: N(ExprNode.Input(0)), inputs: new[] { name }).InputNames);
|
||||
foreach (var name in new[] { "", "9", "@", "[", "`", "{", "/", ":", "a b", "a-b", "é" })
|
||||
Assert.Equal("port-name", Refusal(() => Make(nodes: N(ExprNode.Input(0)), inputs: new[] { name })).Rule);
|
||||
foreach (var name in new[] { "a@", "a[", "a`", "a{", "a/", "a:" })
|
||||
Assert.Equal("port-name", Refusal(() => Make(nodes: N(ExprNode.Input(0)), inputs: new[] { name })).Rule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusalsNameTheirRuleAndNodeAndNothingElse()
|
||||
{
|
||||
var b = new ExprBuilder();
|
||||
var x = b.Input("x");
|
||||
Assert.Equal("unknown-op at node 1", Refusal(() => b.Call("nope", x)).Message);
|
||||
Assert.Equal("port-name", Refusal(() => b.Input("1")).Message);
|
||||
Assert.Equal("version", Refusal(() => Make(nodes: N(ExprNode.Input(0)), version: 3)).Message);
|
||||
Assert.Equal(-1, Refusal(() => b.Input("1")).NodeIndex);
|
||||
Assert.Equal(1, Refusal(() => b.Call("nope", x)).NodeIndex);
|
||||
Assert.Equal(0, new ExprGraphException("r", 0).NodeIndex);
|
||||
Assert.Equal("r at node 0", new ExprGraphException("r", 0).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromWireNamesTheFourLeafKindsAndCalls()
|
||||
{
|
||||
foreach (var kind in new[] { "input", "param", "const", "pick" })
|
||||
Assert.Equal(kind, ExprNode.FromWire(kind, Array.Empty<int>(), 0).WireName);
|
||||
Assert.Equal(ExprNodeKind.Call, ExprNode.FromWire("add", new[] { 0, 0 }, 0).Kind);
|
||||
Assert.Equal(ExprNodeKind.Call, ExprNode.FromWire("nope", Array.Empty<int>(), 0).Kind);
|
||||
Assert.Equal(new[] { ExprNodeKind.Input, ExprNodeKind.Param, ExprNodeKind.Const, ExprNodeKind.Pick },
|
||||
new[] { "input", "param", "const", "pick" }.Select(name => ExprNode.FromWire(name, Array.Empty<int>(), 0).Kind));
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ internal static class GlslLowering
|
|||
{
|
||||
// Phacelle is a port of MPL-2.0 code, and MPL-2.0 covers the files that carry it.
|
||||
["CultMath.Phacelle.hlsl"] = ("CultMath.Phacelle.glsl", "MPL-2.0"),
|
||||
// The Cycles node semantics are a port of Apache-2.0 code, and the licence travels with the file.
|
||||
["CultMath.Cycles.hlsl"] = ("CultMath.Cycles.glsl", "Apache-2.0"),
|
||||
};
|
||||
|
||||
private const string Regenerate =
|
||||
|
|
@ -72,7 +74,9 @@ internal static class GlslLowering
|
|||
source = Regex.Replace(source, @"(?m)^[ \t]*#(ifndef|define|endif)\b.*$", string.Empty);
|
||||
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.
|
||||
// 3. Type names: floatN, intN, uintN and boolN become vecN, ivecN, uvecN and bvecN, and float3x3
|
||||
// becomes mat3 (the one matrix type the library returns: a float3x3 is built from nine scalars,
|
||||
// which HLSL reads as rows and GLSL as columns, so only symmetric matrices are exported).
|
||||
source = LowerTypeNames(source);
|
||||
|
||||
// 4. File-scope `static const` becomes `const`.
|
||||
|
|
@ -84,13 +88,15 @@ internal static class GlslLowering
|
|||
source = LowerCasts(source);
|
||||
|
||||
// 6. Intrinsic renames: lerp -> mix, frac -> fract, rsqrt -> inversesqrt, asuint ->
|
||||
// floatBitsToUint, asfloat -> uintBitsToFloat, and saturate(x) -> clamp(x, 0.0, 1.0).
|
||||
// floatBitsToUint, asfloat -> uintBitsToFloat, atan2 -> atan (GLSL's two-argument arctangent),
|
||||
// and saturate(x) -> clamp(x, 0.0, 1.0).
|
||||
// Identifier boundaries keep cultmath_lerp and the rest of the cultmath_ names intact.
|
||||
source = Regex.Replace(source, @"\blerp\(", "mix(");
|
||||
source = Regex.Replace(source, @"\bfrac\(", "fract(");
|
||||
source = Regex.Replace(source, @"\brsqrt\(", "inversesqrt(");
|
||||
source = Regex.Replace(source, @"\basuint\(", "floatBitsToUint(");
|
||||
source = Regex.Replace(source, @"\basfloat\(", "uintBitsToFloat(");
|
||||
source = Regex.Replace(source, @"\batan2\(", "atan(");
|
||||
source = LowerSaturate(source);
|
||||
|
||||
// 7. Float literals pass through: HLSL's unsuffixed 0.5 and 1.0e30 are GLSL literals already.
|
||||
|
|
@ -106,7 +112,8 @@ internal static class GlslLowering
|
|||
source = Regex.Replace(source, @"\bfloat([234])\b", "vec$1");
|
||||
source = Regex.Replace(source, @"\bint([234])\b", "ivec$1");
|
||||
source = Regex.Replace(source, @"\buint([234])\b", "uvec$1");
|
||||
return Regex.Replace(source, @"\bbool([234])\b", "bvec$1");
|
||||
source = Regex.Replace(source, @"\bbool([234])\b", "bvec$1");
|
||||
return Regex.Replace(source, @"\bfloat3x3\b", "mat3");
|
||||
}
|
||||
|
||||
private static readonly Regex Cast = new(@"\((float|int|uint)\)");
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ public sealed class GlslMirrorTests
|
|||
|
||||
// Identifier boundaries: cultmath_lerp is a CultMath function, lerp( is HLSL.
|
||||
private static readonly Regex HlslOnly = new(
|
||||
@"\b(float|u?int|bool)[234]\b|\blerp\(|\bfrac\(|\bsaturate\(|\brsqrt\(|\basuint\(|\basfloat\(|static const|\((float|int|uint)\)|Texture2D|SamplerState|#include|precision (high|medium|low)p");
|
||||
@"\b(float|u?int|bool)[234]\b|\bfloat3x3\b|\blerp\(|\bfrac\(|\bsaturate\(|\brsqrt\(|\basuint\(|\basfloat\(|\batan2\(|static const|\((float|int|uint)\)|Texture2D|SamplerState|#include|precision (high|medium|low)p");
|
||||
|
||||
[Fact]
|
||||
public void NoHlslOnlyTokensSurvive()
|
||||
|
|
@ -86,6 +86,12 @@ public sealed class GlslMirrorTests
|
|||
Assert.DoesNotContain(token, library);
|
||||
Assert.Contains(token, phacelle);
|
||||
}
|
||||
|
||||
// The Apache-2.0 code is in its own file too, and the MPL-2.0 file holds none of it.
|
||||
var cycles = lowered.Single(output => output.File == "CultMath.Cycles.glsl").Text;
|
||||
Assert.DoesNotContain("cultmath_cycles_divide(", library);
|
||||
Assert.DoesNotContain("cultmath_cycles_divide(", phacelle);
|
||||
Assert.Contains("float cultmath_cycles_divide(", cycles);
|
||||
}
|
||||
|
||||
// The two-file contract a consumer relies on when it concatenates CultMath.Phacelle.glsl after
|
||||
|
|
@ -106,6 +112,12 @@ public sealed class GlslMirrorTests
|
|||
Assert.Contains("cultmath_phacelle is in CultMath.Phacelle.glsl", library);
|
||||
Assert.Contains(" MPL-2.0;", phacelle);
|
||||
Assert.DoesNotContain("MIT", phacelle);
|
||||
|
||||
var cycles = lowered.Single(output => output.File == "CultMath.Cycles.glsl").Text.Split('\n')[0];
|
||||
Assert.Contains("cultmath_cycles_ramp_index is in CultMath.Cycles.glsl", library);
|
||||
Assert.Contains(" Apache-2.0;", cycles);
|
||||
Assert.DoesNotContain("MIT", cycles);
|
||||
Assert.DoesNotContain("MPL", cycles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -503,6 +515,12 @@ public sealed class GlslMirrorTests
|
|||
"Tightness: R0 + d <= GPU radius <= R0 + d + 2^-19 (R0 + |c0|_1 + spread). " +
|
||||
"Report the least slack of each side; only the bounds fail the check.";
|
||||
|
||||
// The Cycles node families' operands: values past [0, 1] on both sides, so the clamps, the extrapolating
|
||||
// factors and the divisions by a small number all show.
|
||||
private static float CyclesValue(System.Random r) => Uniform(r, -20.0f, 20.0f);
|
||||
private static float CyclesFactor(System.Random r) => Uniform(r, -0.5f, 1.5f);
|
||||
private static float3 CyclesColor(System.Random r) => new(Uniform(r, -0.25f, 1.25f), Uniform(r, -0.25f, 1.25f), Uniform(r, -0.25f, 1.25f));
|
||||
|
||||
// A Phacelle stripe wave vector: each component in [-6, 6], about one stripe per cell.
|
||||
private static float3 Side(System.Random r) => new(Uniform(r, -6.0f, 6.0f), Uniform(r, -6.0f, 6.0f), Uniform(r, -6.0f, 6.0f));
|
||||
|
||||
|
|
@ -511,7 +529,7 @@ public sealed class GlslMirrorTests
|
|||
// float families are ulp-bounded, with the bound measured on the device by the consumer, and
|
||||
// iv_frustum_ball also carries an enclosure check. phacelle is in CultMath.Phacelle.glsl, so its
|
||||
// evaluator concatenates that file after CultMath.glsl; the site, which does not vendor it, skips it.
|
||||
private static readonly Family[] Families =
|
||||
private static readonly Family[] Families = new Family[]
|
||||
{
|
||||
new("float cultmath_snoise(vec3)", "ulp-bounded", r => new object[] { Point3(r) }),
|
||||
new("float cultmath_snoise(vec2)", "ulp-bounded", r => new object[] { new float2(Uniform(r, -50.0f, 50.0f), Uniform(r, -50.0f, 50.0f)) }),
|
||||
|
|
@ -575,7 +593,52 @@ public sealed class GlslMirrorTests
|
|||
new("vec2 cultmath_iv_snoise_ball(vec2, float)", "ulp-bounded", r => new object[] { new float2(Uniform(r, -50.0f, 50.0f), Uniform(r, -50.0f, 50.0f)), Uniform(r, 0.001f, 2.0f) }),
|
||||
new("vec3 cultmath_af_snoise(vec3, vec3, float)", "ulp-bounded, reach 1e-6 to 1e-3", SmallReachSlice, SnoiseSmallForm, Verify: cases => AssertSnoiseForm(cases, allowanceDecides: true)),
|
||||
new("vec3 cultmath_af_fbm(vec3, vec3, float, int, float, float)", "ulp-bounded, reach 1e-6 to 1e-3", r => SmallReachSlice(r).Concat(new object[] { 4, 2.0f, 0.5f }).ToArray(), FbmSmallForm, Verify: cases => AssertFbmForm(cases, allowanceDecides: true)),
|
||||
};
|
||||
new("float cultmath_cycles_divide(float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_power(float, float)", "ulp-bounded, platform pow", r => new object[] { Uniform(r, -4.0f, 4.0f), r.Next(2) == 0 ? r.Next(-4, 5) : Uniform(r, -3.0f, 3.0f) }, Platform: Platform.Ulp),
|
||||
new("float cultmath_cycles_logarithm(float, float)", "ulp-bounded, platform log", r => new object[] { Uniform(r, 0.01f, 20.0f), Uniform(r, 0.01f, 20.0f) }, Platform: Platform.Ulp),
|
||||
new("float cultmath_cycles_sqrt(float)", "ulp-bounded", r => new object[] { CyclesValue(r) }),
|
||||
new("float cultmath_cycles_inverse_sqrt(float)", "ulp-bounded", r => new object[] { CyclesValue(r) }),
|
||||
new("float cultmath_cycles_modulo(float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_floored_modulo(float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_wrap(float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), Uniform(r, -10.0f, 10.0f), Uniform(r, -10.0f, 10.0f) }),
|
||||
new("float cultmath_cycles_snap(float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_pingpong(float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_smooth_min(float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), Uniform(r, -5.0f, 5.0f) }),
|
||||
new("float cultmath_cycles_smooth_max(float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), Uniform(r, -5.0f, 5.0f) }),
|
||||
new("float cultmath_cycles_compare(float, float, float)", "ulp-bounded", r => { var a = CyclesValue(r); return new object[] { a, a + Uniform(r, -1.0f, 1.0f) * 1.0e-3f, Uniform(r, 0.0f, 2.0e-3f) }; }),
|
||||
new("float cultmath_cycles_round(float)", "ulp-bounded", r => new object[] { CyclesValue(r) }),
|
||||
new("float cultmath_cycles_trunc(float)", "ulp-bounded", r => new object[] { CyclesValue(r) }),
|
||||
new("float cultmath_cycles_fract(float)", "ulp-bounded", r => new object[] { CyclesValue(r) }),
|
||||
new("float cultmath_cycles_sign(float)", "ulp-bounded", r => new object[] { r.Next(4) == 0 ? 0.0f : CyclesValue(r) }),
|
||||
new("float cultmath_cycles_map_range_linear(float, float, float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_map_range_stepped(float, float, float, float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r), Uniform(r, -1.0f, 8.0f) }),
|
||||
new("float cultmath_cycles_map_range_smoothstep(float, float, float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_map_range_smootherstep(float, float, float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_clamp_minmax(float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_clamp_range(float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_mix_float(float, float, float)", "ulp-bounded", r => new object[] { CyclesFactor(r), CyclesValue(r), CyclesValue(r) }),
|
||||
new("vec3 cultmath_cycles_mix_vector(float, vec3, vec3)", "ulp-bounded", r => new object[] { CyclesFactor(r), CyclesColor(r), CyclesColor(r) }),
|
||||
new("vec3 cultmath_cycles_rgb_to_hsv(vec3)", "ulp-bounded", r => new object[] { CyclesColor(r) }),
|
||||
new("vec3 cultmath_cycles_hsv_to_rgb(vec3)", "ulp-bounded", r => new object[] { new float3(r.NextSingle(), r.NextSingle(), Uniform(r, 0.0f, 2.0f)) }),
|
||||
new("float cultmath_cycles_rgb_to_bw(vec3)", "ulp-bounded", r => new object[] { CyclesColor(r) }),
|
||||
new("vec2 cultmath_cycles_ramp_index(float, int)", "ulp-bounded", r => new object[] { Uniform(r, -0.25f, 1.25f), 257 }),
|
||||
}.Concat(new[]
|
||||
{
|
||||
"blend", "add", "mul", "screen", "overlay", "sub", "div", "diff", "exclusion", "dark", "light", "dodge", "burn", "hue", "sat", "val", "color", "soft", "linear",
|
||||
}.Select(mode => new Family($"vec3 cultmath_cycles_mix_{mode}(float, vec3, vec3)", "ulp-bounded", r => new object[] { CyclesFactor(r), CyclesColor(r), CyclesColor(r) }))).Concat(new[]
|
||||
{
|
||||
new Family("float cultmath_cycles_arcsine(float)", "ulp-bounded, platform asin", r => new object[] { Uniform(r, -1.5f, 1.5f) }, Platform: Platform.Ulp),
|
||||
new Family("float cultmath_cycles_arccosine(float)", "ulp-bounded, platform acos", r => new object[] { Uniform(r, -1.5f, 1.5f) }, Platform: Platform.Ulp),
|
||||
new Family("float cultmath_cycles_arctan2(float, float)", "ulp-bounded, platform atan", r => new object[] { Uniform(r, -20.0f, 20.0f), Uniform(r, -20.0f, 20.0f) }, Platform: Platform.Ulp),
|
||||
new Family("mat3 cultmath_snoise_hessian(vec3)", "ulp-bounded", r => new object[] { Point3(r) }),
|
||||
new Family("mat3 cultmath_snoise_hessian(vec3)", "ulp-bounded, on the simplex tie set", r => new object[] { TiePoint3(r) }),
|
||||
new Family("float cultmath_integral_clamp_linear(float, float, float, float, float, float)", "ulp-bounded",
|
||||
r =>
|
||||
{
|
||||
var lo = Uniform(r, -2.0f, 0.5f);
|
||||
return new object[] { Uniform(r, -4.0f, 4.0f), r.Next(8) == 0 ? 0.0f : Uniform(r, -4.0f, 4.0f), lo, lo + Uniform(r, 0.0f, 3.0f), Uniform(r, -3.0f, 3.0f), Uniform(r, -3.0f, 3.0f) };
|
||||
}),
|
||||
}).ToArray();
|
||||
|
||||
// The fixture text, each case's arguments given by family and point index.
|
||||
private static string GoldenFixture(Func<int, int, object[]> arguments)
|
||||
|
|
@ -638,8 +701,12 @@ public sealed class GlslMirrorTests
|
|||
_ => throw new InvalidOperationException(v.GetType().Name),
|
||||
}));
|
||||
|
||||
// A float3x3 flattens row by row; the matrices the library exports are symmetric, so the order is
|
||||
// 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 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)!));
|
||||
|
||||
// ---- A small unified diff for the failure message ----
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ public sealed class HlslMirrorTests
|
|||
[InlineData("CultMath.Phacelle.hlsl")]
|
||||
[InlineData("CultMath.Interval.hlsl")]
|
||||
[InlineData("CultMath.Affine.hlsl")]
|
||||
[InlineData("CultMath.Cycles.hlsl")]
|
||||
public void UnityPackageShaderIsIdenticalToTheCanonicalShader(string name)
|
||||
{
|
||||
var root = GetCultMathRoot();
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ public sealed class HlslSourceCompatibilityTests
|
|||
Assert.NotNull(ShaderFunction(shader, "cultmath_smin_grad", typeof(float4), typeof(float4), typeof(float)));
|
||||
Assert.NotNull(ShaderFunction(shader, "cultmath_cellular", typeof(float3)));
|
||||
Assert.NotNull(ShaderFunction(shader, "cultmath_snoise_grad", typeof(float3)));
|
||||
Assert.NotNull(ShaderFunction(shader, "cultmath_snoise_hessian", typeof(float3)));
|
||||
Assert.NotNull(ShaderFunction(shader, "cultmath_integral_clamp_linear", typeof(float), typeof(float), typeof(float), typeof(float), typeof(float), typeof(float)));
|
||||
Assert.NotNull(ShaderFunction(shader, "cultmath_fbm_grad", typeof(float3), typeof(int), typeof(float), typeof(float)));
|
||||
Assert.NotNull(ShaderFunction(shader, "cultmath_ridged_grad", typeof(float3), typeof(int), typeof(float), typeof(float)));
|
||||
Assert.NotNull(ShaderFunction(shader, "cultmath_phacelle", typeof(float3), typeof(float3), typeof(float), typeof(float)));
|
||||
|
|
@ -137,7 +139,7 @@ public sealed class HlslSourceCompatibilityTests
|
|||
}
|
||||
|
||||
Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}");
|
||||
Assert.Equal(67, compared.Count);
|
||||
Assert.Equal(120, compared.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -377,6 +379,9 @@ public sealed class HlslSourceCompatibilityTests
|
|||
/// </summary>
|
||||
private static Leaf[] Values(object value, string path = "") => Scalars.Contains(value.GetType())
|
||||
? new[] { new Leaf(path, value.GetType(), value) }
|
||||
: value is float3x3 matrix
|
||||
? new[] { matrix._m00, matrix._m01, matrix._m02, matrix._m10, matrix._m11, matrix._m12, matrix._m20, matrix._m21, matrix._m22 }
|
||||
.Select((entry, k) => new Leaf((path.Length == 0 ? "" : path + ".") + "_m" + k / 3 + k % 3, typeof(float), entry)).ToArray()
|
||||
: value.GetType().GetFields().Where(f => !f.IsStatic)
|
||||
.SelectMany(f => Values(f.GetValue(value)!, path.Length == 0 ? f.Name : path + "." + f.Name))
|
||||
.ToArray();
|
||||
|
|
@ -433,7 +438,7 @@ public sealed class HlslSourceCompatibilityTests
|
|||
return "using CultMath;\nusing static CultMath.math;\nnamespace CultMathHlsl;\npublic class HlslShader\n{\n" + source + "\n}\n";
|
||||
}
|
||||
|
||||
private static (Assembly? Assembly, IReadOnlyList<string> Errors) CompileShaderMirror()
|
||||
internal static (Assembly? Assembly, IReadOnlyList<string> Errors) CompileShaderMirror()
|
||||
{
|
||||
var hlsl = ReadShaderSource(FindCultMathRoot());
|
||||
var tree = CSharpSyntaxTree.ParseText(TransformHlslToCSharp(hlsl));
|
||||
|
|
|
|||
|
|
@ -489,25 +489,13 @@ public sealed class NoiseBoundTests
|
|||
// the quotient is not skewed by the rounding of p + h far from the origin.
|
||||
private static double Step(float x, float h, out double back) { back = x - (double)(x - h); return (x + h) - (double)x; }
|
||||
|
||||
// The spectral norm (largest absolute eigenvalue) of the symmetrised Hessian of snoise(float3) at p,
|
||||
// from central differences of snoise_grad at h = 1e-3.
|
||||
// The spectral norm (largest absolute eigenvalue) of snoise's Hessian at p: the library's own
|
||||
// snoise_hessian, symmetric bit for bit, then the closed-form eigenvalues of a symmetric 3x3.
|
||||
private static double HessianNorm(float3 p)
|
||||
{
|
||||
const float h = 1.0e-3f;
|
||||
var m = new double[3, 3];
|
||||
for (var a = 0; a < 3; a++)
|
||||
{
|
||||
var e = new float3(a == 0 ? h : 0.0f, a == 1 ? h : 0.0f, a == 2 ? h : 0.0f);
|
||||
var forward = Step(a == 0 ? p.x : a == 1 ? p.y : p.z, h, out var back);
|
||||
var gp = snoise_grad(p + e);
|
||||
var gm = snoise_grad(p - e);
|
||||
m[0, a] = (gp.x - (double)gm.x) / (forward + back);
|
||||
m[1, a] = (gp.y - (double)gm.y) / (forward + back);
|
||||
m[2, a] = (gp.z - (double)gm.z) / (forward + back);
|
||||
}
|
||||
|
||||
var (a00, a11, a22) = (m[0, 0], m[1, 1], m[2, 2]);
|
||||
var (a01, a02, a12) = ((m[0, 1] + m[1, 0]) / 2, (m[0, 2] + m[2, 0]) / 2, (m[1, 2] + m[2, 1]) / 2);
|
||||
var m = snoise_hessian(p);
|
||||
var (a00, a11, a22) = ((double)m._m00, (double)m._m11, (double)m._m22);
|
||||
var (a01, a02, a12) = ((double)m._m01, (double)m._m02, (double)m._m12);
|
||||
var p1 = a01 * a01 + a02 * a02 + a12 * a12;
|
||||
if (p1 == 0.0)
|
||||
return Math.Max(Math.Abs(a00), Math.Max(Math.Abs(a11), Math.Abs(a22)));
|
||||
|
|
@ -617,7 +605,7 @@ public sealed class NoiseBoundTests
|
|||
}
|
||||
|
||||
// The start MeasureHessian printed as its witness.
|
||||
private static readonly float3 HessianWitness = new(-245.19885f, -53.01323f, -91.47363f);
|
||||
private static readonly float3 HessianWitness = new(-13.7920685f, -9.672134f, 61.563934f);
|
||||
|
||||
/// <summary>
|
||||
/// Pins SNOISE_HESSIAN: no Hessian norm over 1e5 seeded points exceeds it, and the ascent from
|
||||
|
|
@ -635,6 +623,181 @@ public sealed class NoiseBoundTests
|
|||
Assert.True(Math.Abs(ratio - 1.0) <= 0.005, $"SNOISE_HESSIAN is {ratio:R} times 1.10 x the re-measured {refined:R}; re-run MeasureHessian and re-pin");
|
||||
}
|
||||
|
||||
// ---- The third-derivative constant (math.Span.cs) ----
|
||||
|
||||
// The symmetric third-derivative tensor of snoise at p, entry (i * 3 + j) * 3 + k: central differences
|
||||
// of snoise_hessian along each axis at h = 1e-3 over the exact float32 steps, symmetrised over the six
|
||||
// index orders (the Hessian is symmetric, so only the derivative index can differ).
|
||||
private static double[] ThirdTensor(float3 p)
|
||||
{
|
||||
const float h = 1.0e-3f;
|
||||
var d = new double[27];
|
||||
for (var k = 0; k < 3; k++)
|
||||
{
|
||||
var e = new float3(k == 0 ? h : 0.0f, k == 1 ? h : 0.0f, k == 2 ? h : 0.0f);
|
||||
var forward = Step(k == 0 ? p.x : k == 1 ? p.y : p.z, h, out var back);
|
||||
var plus = snoise_hessian(p + e);
|
||||
var minus = snoise_hessian(p - e);
|
||||
for (var i = 0; i < 3; i++)
|
||||
for (var j = 0; j < 3; j++)
|
||||
d[(i * 3 + j) * 3 + k] = (plus[i][j] - (double)minus[i][j]) / (forward + back);
|
||||
}
|
||||
|
||||
var t = new double[27];
|
||||
for (var i = 0; i < 3; i++)
|
||||
for (var j = 0; j < 3; j++)
|
||||
for (var k = 0; k < 3; k++)
|
||||
t[(i * 3 + j) * 3 + k] = (d[(i * 3 + j) * 3 + k] + d[(i * 3 + k) * 3 + j] + d[(j * 3 + i) * 3 + k]
|
||||
+ d[(j * 3 + k) * 3 + i] + d[(k * 3 + i) * 3 + j] + d[(k * 3 + j) * 3 + i]) / 6.0;
|
||||
return t;
|
||||
}
|
||||
|
||||
// T(u, u, u) for a symmetric 3-tensor.
|
||||
private static double Cubic(double[] t, double[] u)
|
||||
{
|
||||
var sum = 0.0;
|
||||
for (var i = 0; i < 3; i++)
|
||||
for (var j = 0; j < 3; j++)
|
||||
for (var k = 0; k < 3; k++)
|
||||
sum += t[(i * 3 + j) * 3 + k] * u[i] * u[j] * u[k];
|
||||
return sum;
|
||||
}
|
||||
|
||||
// The 26 unit directions of the axes, the edge midpoints and the cube corners.
|
||||
private static readonly double[][] TensorStarts = Enumerable.Range(0, 27).Select(c => new[] { c % 3 - 1.0, c / 3 % 3 - 1.0, c / 9 - 1.0 })
|
||||
.Where(v => v[0] != 0.0 || v[1] != 0.0 || v[2] != 0.0)
|
||||
.Select(v => { var length = Math.Sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); return new[] { v[0] / length, v[1] / length, v[2] / length }; }).ToArray();
|
||||
|
||||
// The operator norm of a symmetric 3-tensor, max over unit u of |T(u, u, u)| (for a symmetric tensor
|
||||
// that is the injective norm). From the six of 26 starts (axes, edge midpoints, cube corners) with
|
||||
// the largest |T(u, u, u)|, the shifted power ascent u <- normalise(T(u, u, .) +- alpha u), with alpha
|
||||
// twice the largest absolute row sum, which makes the iteration monotone in the cubic form (Kolda and
|
||||
// Mayo's SS-HOPM), the sign chosen as the start's. A local maximum, so a lower bound; MeasureThird
|
||||
// checks it against a dense direction sweep at its witness.
|
||||
private static double TensorNorm(double[] t)
|
||||
{
|
||||
var alpha = 0.0;
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var row = 0.0;
|
||||
for (var jk = 0; jk < 9; jk++)
|
||||
row += Math.Abs(t[i * 9 + jk]);
|
||||
alpha = Math.Max(alpha, 2.0 * row);
|
||||
}
|
||||
|
||||
var best = 0.0;
|
||||
var g = new double[3];
|
||||
foreach (var start in TensorStarts.OrderByDescending(v => Math.Abs(Cubic(t, v))).Take(6))
|
||||
{
|
||||
var u = (double[])start.Clone();
|
||||
var sign = Cubic(t, u) < 0.0 ? -1.0 : 1.0;
|
||||
for (var iteration = 0; iteration < 40; iteration++)
|
||||
{
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var sum = 0.0;
|
||||
for (var j = 0; j < 3; j++)
|
||||
for (var k = 0; k < 3; k++)
|
||||
sum += t[(i * 3 + j) * 3 + k] * u[j] * u[k];
|
||||
g[i] = sign * sum + alpha * u[i];
|
||||
}
|
||||
|
||||
var length = Math.Sqrt(g[0] * g[0] + g[1] * g[1] + g[2] * g[2]);
|
||||
if (length == 0.0)
|
||||
break;
|
||||
var moved = 0.0;
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var next = g[i] / length;
|
||||
moved += (next - u[i]) * (next - u[i]);
|
||||
u[i] = next;
|
||||
}
|
||||
|
||||
if (moved < 1.0e-20)
|
||||
break;
|
||||
}
|
||||
|
||||
best = Math.Max(best, Math.Abs(Cubic(t, u)));
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// The operator norm of snoise's third-derivative tensor at p.
|
||||
private static double ThirdNorm(float3 p) => TensorNorm(ThirdTensor(p));
|
||||
|
||||
[Fact]
|
||||
public void TensorNormFindsKnownMaxima()
|
||||
{
|
||||
// T = e_x (x) e_x (x) e_x: the cubic form is u_x^3, norm 1.
|
||||
var single = new double[27];
|
||||
single[0] = 1.0;
|
||||
Assert.InRange(TensorNorm(single), 1.0 - 1.0e-9, 1.0 + 1.0e-9);
|
||||
|
||||
// The six permutations of (0, 1, 2) at 1: the cubic form is 6 u_x u_y u_z, largest on the cube
|
||||
// diagonal at 6 / (3 sqrt 3) = 2 / sqrt 3.
|
||||
var permutations = new double[27];
|
||||
foreach (var (i, j, k) in new[] { (0, 1, 2), (0, 2, 1), (1, 0, 2), (1, 2, 0), (2, 0, 1), (2, 1, 0) })
|
||||
permutations[(i * 3 + j) * 3 + k] = 1.0;
|
||||
Assert.InRange(TensorNorm(permutations), 2.0 / Math.Sqrt(3.0) - 1.0e-9, 2.0 / Math.Sqrt(3.0) + 1.0e-9);
|
||||
|
||||
// A negative extremum counts by its absolute value.
|
||||
var negative = new double[27];
|
||||
negative[13] = -3.0;
|
||||
Assert.InRange(TensorNorm(negative), 3.0 - 1.0e-9, 3.0 + 1.0e-9);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The provenance of SNOISE_THIRD: the largest operator norm of snoise's third-derivative tensor
|
||||
/// (ThirdNorm) over 1e6 seeded points of [-256, 256]^3, the 1e4 largest refined by ascent over the point,
|
||||
/// times 1.10, on the tied kernel. Prints the start whose ascent reached the maximum, which
|
||||
/// ThirdConstantPinsSampled climbs from again, and a sweep of 20,000 directions at the point the
|
||||
/// ascent ends on (the tensor norm there against the largest cubic form the sweep finds, so the
|
||||
/// tightness of the inner maximisation is on the record). Slow; run explicitly after any change to the
|
||||
/// snoise kernel and re-pin the constant and the witness.
|
||||
/// </summary>
|
||||
[Fact(Explicit = true)]
|
||||
[Trait("Category", "Slow")]
|
||||
public void MeasureThird()
|
||||
{
|
||||
var (sampled, refined, witness) = MeasureMax(ThirdNorm, false, 0x4E57, 1_000_000, 10_000);
|
||||
output.WriteLine($"IV-REPORT third: max |D3| sampled {sampled:R}, refined {refined:R}, x 1.10 = {(float)(refined * 1.10):R}, witness ({witness.x:R}, {witness.y:R}, {witness.z:R})");
|
||||
var (best, at) = AscendPoint(ThirdNorm, witness);
|
||||
var tensor = ThirdTensor(at);
|
||||
var sweep = 0.0;
|
||||
const int directions = 20_000;
|
||||
for (var k = 0; k < directions; k++)
|
||||
{
|
||||
var z = 1.0 - 2.0 * (k + 0.5) / directions;
|
||||
var r = Math.Sqrt(1.0 - z * z);
|
||||
var phi = k * Math.PI * (3.0 - Math.Sqrt(5.0));
|
||||
sweep = Math.Max(sweep, Math.Abs(Cubic(tensor, new[] { r * Math.Cos(phi), r * Math.Sin(phi), z })));
|
||||
}
|
||||
|
||||
output.WriteLine($"IV-REPORT third witness ascent ends at ({at.x:R}, {at.y:R}, {at.z:R}): tensor norm {best:R}, dense sweep of {directions} directions {sweep:R} ({sweep / best:R} of it)");
|
||||
Assert.True(sweep <= best * (1.0 + 1.0e-6), $"a direction sweep finds {sweep:R} over the ascent's {best:R}: the inner maximisation misses a maximum");
|
||||
Assert.True(refined <= SNOISE_THIRD, $"measured {refined:R} exceeds SNOISE_THIRD {SNOISE_THIRD:R}");
|
||||
}
|
||||
|
||||
// The start MeasureThird printed as its witness.
|
||||
private static readonly float3 ThirdWitness = new(-168.3755f, -3.8205261f, -109.16948f);
|
||||
|
||||
/// <summary>
|
||||
/// Pins SNOISE_THIRD: no third-derivative norm over 1e5 seeded points exceeds it, and the ascent from
|
||||
/// MeasureThird's witness reaches a maximum that, times 1.10, is the constant to within half a
|
||||
/// percent, so a constant moved by 1% fails here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ThirdConstantPinsSampled()
|
||||
{
|
||||
var (sampled, _, _) = MeasureMax(ThirdNorm, false, 0x4E58, 100_000, 0);
|
||||
Assert.True(sampled <= SNOISE_THIRD, $"the third-derivative norm reaches {sampled:R}, above SNOISE_THIRD {SNOISE_THIRD:R}");
|
||||
var refined = Ascend(ThirdNorm, ThirdWitness);
|
||||
Assert.True(refined <= SNOISE_THIRD, $"the third-derivative norm reaches {refined:R}, above SNOISE_THIRD {SNOISE_THIRD:R}");
|
||||
var ratio = SNOISE_THIRD / (1.10 * refined);
|
||||
Assert.True(Math.Abs(ratio - 1.0) <= 0.005, $"SNOISE_THIRD is {ratio:R} times 1.10 x the re-measured {refined:R}; re-run MeasureThird and re-pin");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SNOISE_HESSIAN where the curvature is realised. At the point the ascent from MeasureHessian's witness
|
||||
/// reaches, the absolute second difference of snoise along 4,000 seeded directions u at step 0.05 has a
|
||||
|
|
|
|||
341
packages/cultmath/tests/CultMath.Tests/SpanPrimitiveTests.cs
Normal file
341
packages/cultmath/tests/CultMath.Tests/SpanPrimitiveTests.cs
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
using CultMath;
|
||||
using Xunit;
|
||||
using static CultMath.math;
|
||||
|
||||
namespace CultMath.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The span primitives (math.Span.cs): snoise_hessian against the finite differences of snoise_grad and of
|
||||
/// snoise, with each tolerance measured by Richardson's two step sizes rather than assumed; its symmetry and
|
||||
/// its continuity across the simplex diagonal; and integral_clamp_linear against adaptive quadrature in
|
||||
/// double. SNOISE_THIRD is measured and pinned in NoiseBoundTests (MeasureThird, ThirdConstantPinsSampled).
|
||||
///
|
||||
/// Hand mutations these must kill: any term of the per-corner second derivative dropped or mis-signed
|
||||
/// (48 m^2 s x_i x_j, 8 m^3 p_j x_i, 8 m^3 p_i x_j, 8 m^3 s delta_ij) fails
|
||||
/// SnoiseHessianMatchesCentralDifferences; the 0.3.0 cyclic tie order fails
|
||||
/// SnoiseHessianContinuousOnSimplexDiagonal; a crossing of integral_clamp_linear moved, a clamp piece
|
||||
/// dropped or the reversed-span sign lost fails IntegralClampLinearMatchesQuadrature.
|
||||
/// </summary>
|
||||
public sealed class SpanPrimitiveTests
|
||||
{
|
||||
private readonly ITestOutputHelper output;
|
||||
|
||||
public SpanPrimitiveTests(ITestOutputHelper output) => this.output = output;
|
||||
|
||||
private const double Unit = 1.0 / 16777216.0;
|
||||
|
||||
// A point of [-50, 50]^3 on the 2^-10 lattice, so that p +- h is exact in float32 for h a power of two
|
||||
// up to 2^-4 and the finite-difference quotients carry no step rounding.
|
||||
private static float3 LatticePoint(System.Random random)
|
||||
{
|
||||
float Axis() => MathF.Round((random.NextSingle() * 100.0f - 50.0f) * 1024.0f) / 1024.0f;
|
||||
return new float3(Axis(), Axis(), Axis());
|
||||
}
|
||||
|
||||
// The simplex tie set of NoiseGradTests.SnoiseIsContinuousOnTheSimplexDiagonal: the line (t, t, t) and
|
||||
// the lattice diagonals v + s (1, 1, 1), v = L - sum(L) / 6.
|
||||
private static List<float3> TiePoints()
|
||||
{
|
||||
const int vertices = 160;
|
||||
const int perVertex = 8;
|
||||
var random = new System.Random(0x71E5);
|
||||
var points = new List<float3>();
|
||||
for (var k = 0; k < 400; k++)
|
||||
{
|
||||
var t = (float)(random.NextDouble() * 128.0 - 64.0);
|
||||
points.Add(new float3(t, t, t));
|
||||
}
|
||||
|
||||
points.Add(new float3(0.0f, 0.0f, 0.0f));
|
||||
for (var k = 0; k < vertices; k++)
|
||||
{
|
||||
var lx = random.Next(-64, 65);
|
||||
var ly = random.Next(-64, 65);
|
||||
var lz = random.Next(-64, 65);
|
||||
var shift = (lx + ly + lz) / 6.0f;
|
||||
for (var j = 0; j < perVertex; j++)
|
||||
{
|
||||
var s = j == 0 ? 0.0f : random.NextSingle();
|
||||
points.Add(new float3(lx - shift + s, ly - shift + s, lz - shift + s));
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private static float3 Axis(int a, float h) => new(a == 0 ? h : 0.0f, a == 1 ? h : 0.0f, a == 2 ? h : 0.0f);
|
||||
|
||||
// Column a of the Hessian by the central difference of snoise_grad at step h, in double.
|
||||
private static double[] GradientDifference(float3 p, int a, float h)
|
||||
{
|
||||
var plus = snoise_grad(p + Axis(a, h));
|
||||
var minus = snoise_grad(p - Axis(a, h));
|
||||
return new[] { (plus.x - (double)minus.x) / (2.0 * h), (plus.y - (double)minus.y) / (2.0 * h), (plus.z - (double)minus.z) / (2.0 * h) };
|
||||
}
|
||||
|
||||
// Worst ratio of |H - D(h/2)| to its tolerance, the number of entries compared and the largest |H| met.
|
||||
private (double Worst, int Compared, double LargestEntry, double LargestTolerance) CompareToGradientDifferences(IEnumerable<float3> points)
|
||||
{
|
||||
const float h = 1.0f / 256.0f;
|
||||
double worst = 0.0, largest = 0.0, largestTolerance = 0.0;
|
||||
var compared = 0;
|
||||
foreach (var p in points)
|
||||
{
|
||||
var hessian = snoise_hessian(p);
|
||||
var gradient = snoise_grad(p);
|
||||
var scale = Math.Max(Math.Max(Math.Abs(gradient.x), Math.Abs(gradient.y)), Math.Max(Math.Abs(gradient.z), 1.0));
|
||||
for (var a = 0; a < 3; a++)
|
||||
{
|
||||
var coarse = GradientDifference(p, a, h);
|
||||
var fine = GradientDifference(p, a, h / 2.0f);
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
// The truncation of the finer quotient is a third of the gap between the two, as the
|
||||
// error is O(h^2); the roundoff is a few ulp of the gradient over the step. Both are
|
||||
// measured per entry and tripled, so the tolerance follows the point's own roughness.
|
||||
var tolerance = 3.0 * (Math.Abs(coarse[i] - fine[i]) + 64.0 * Unit * scale / (h / 2.0));
|
||||
var error = Math.Abs(hessian[i][a] - fine[i]);
|
||||
worst = Math.Max(worst, error / tolerance);
|
||||
largest = Math.Max(largest, Math.Abs(hessian[i][a]));
|
||||
largestTolerance = Math.Max(largestTolerance, tolerance);
|
||||
Assert.True(error <= tolerance, $"snoise_hessian({p})[{i}][{a}] = {hessian[i][a]:R} differs from the central difference of snoise_grad {fine[i]:R} by {error:R}, over the measured tolerance {tolerance:R}");
|
||||
compared++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (worst, compared, largest, largestTolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnoiseHessianMatchesCentralDifferences()
|
||||
{
|
||||
var random = new System.Random(0x5A11);
|
||||
var points = Enumerable.Range(0, 10_000).Select(_ => LatticePoint(random)).ToList();
|
||||
var (worst, compared, largest, largestTolerance) = CompareToGradientDifferences(points);
|
||||
output.WriteLine($"snoise_hessian vs central differences of snoise_grad over {points.Count} seeded points: {compared} entries, worst error {worst:F3} of its Richardson tolerance, largest |H| {largest:R}, largest tolerance {largestTolerance:R}");
|
||||
|
||||
// The comparison has teeth: curvature of the size the constant claims is present, and the tolerance
|
||||
// is far under it, so a dropped term of the second derivative cannot hide in it.
|
||||
Assert.True(largest > 10.0, $"the sample never meets curvature above 10 (largest {largest:R})");
|
||||
Assert.True(largestTolerance < 0.5, $"the tolerance reaches {largestTolerance:R}, too wide to see a dropped term");
|
||||
|
||||
var (tieWorst, tieCompared, tieLargest, tieTolerance) = CompareToGradientDifferences(TiePoints());
|
||||
output.WriteLine($"snoise_hessian vs central differences of snoise_grad on the simplex tie set: {tieCompared} entries, worst error {tieWorst:F3} of its Richardson tolerance, largest |H| {tieLargest:R}, largest tolerance {tieTolerance:R}");
|
||||
Assert.True(tieLargest > 1.0, $"the tie set never meets curvature above 1 (largest {tieLargest:R})");
|
||||
}
|
||||
|
||||
// The second difference of snoise in double, h apart, for the entry (i, j): the three-point stencil
|
||||
// for i = j, the four-point mixed stencil otherwise.
|
||||
private static double SecondDifference(float3 p, int i, int j, float h)
|
||||
{
|
||||
if (i == j)
|
||||
return (snoise(p + Axis(i, h)) - 2.0 * (double)snoise(p) + snoise(p - Axis(i, h))) / ((double)h * h);
|
||||
var pp = snoise(p + Axis(i, h) + Axis(j, h));
|
||||
var pm = snoise(p + Axis(i, h) - Axis(j, h));
|
||||
var mp = snoise(p - Axis(i, h) + Axis(j, h));
|
||||
var mm = snoise(p - Axis(i, h) - Axis(j, h));
|
||||
return ((double)pp - pm - mp + mm) / (4.0 * h * h);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnoiseHessianMatchesSecondDifferencesOfSnoise()
|
||||
{
|
||||
var random = new System.Random(0x5A12);
|
||||
double worst = 0.0, largest = 0.0;
|
||||
var compared = 0;
|
||||
var outside = 0;
|
||||
for (var k = 0; k < 4000; k++)
|
||||
{
|
||||
var p = LatticePoint(random);
|
||||
var hessian = snoise_hessian(p);
|
||||
for (var i = 0; i < 3; i++)
|
||||
for (var j = i; j < 3; j++)
|
||||
{
|
||||
// Richardson on the O(h^2) second difference: (4 S(h/2) - S(h)) / 3 is O(h^4). Two such
|
||||
// extrapolations, from (h, h/2) and from (h/2, h/4), differ by about fifteen times the
|
||||
// error of the finer one, so the comparison is against the finer and its tolerance is
|
||||
// four times that gap, plus the roundoff of snoise (a few ulp) over (h/4)^2. Near a
|
||||
// kernel's edge (m = 0) snoise's fourth derivative jumps and the extrapolation loses its
|
||||
// order: such an entry may exceed the tight tolerance, but never by more than the gap
|
||||
// between the last two second differences (an error that shrinks at least linearly in h
|
||||
// is at most that gap), and the tight tolerance must hold for all but a fraction.
|
||||
const float h = 1.0f / 16.0f;
|
||||
var s0 = SecondDifference(p, i, j, h);
|
||||
var s1 = SecondDifference(p, i, j, h / 2.0f);
|
||||
var s2 = SecondDifference(p, i, j, h / 4.0f);
|
||||
var coarseExtrapolation = (4.0 * s1 - s0) / 3.0;
|
||||
var extrapolated = (4.0 * s2 - s1) / 3.0;
|
||||
var tolerance = 4.0 * Math.Abs(extrapolated - coarseExtrapolation) + 64.0 * Unit / ((h / 4.0) * (h / 4.0));
|
||||
var loose = tolerance + 2.0 * Math.Abs(s1 - s2);
|
||||
var error = Math.Abs(hessian[i][j] - extrapolated);
|
||||
worst = Math.Max(worst, error / tolerance);
|
||||
largest = Math.Max(largest, Math.Abs(hessian[i][j]));
|
||||
compared++;
|
||||
if (error > tolerance)
|
||||
outside++;
|
||||
Assert.True(error <= loose, $"snoise_hessian({p})[{i}][{j}] = {hessian[i][j]:R} differs from the extrapolated second difference of snoise {extrapolated:R} by {error:R}, over the measured tolerance {loose:R}");
|
||||
}
|
||||
}
|
||||
|
||||
output.WriteLine($"snoise_hessian vs Richardson second differences of snoise over 4000 seeded points: {compared} entries, {outside} over the tight tolerance, worst error {worst:F3} of it, largest |H| {largest:R}");
|
||||
Assert.True(largest > 10.0, $"the sample never meets curvature above 10 (largest {largest:R})");
|
||||
Assert.True(outside <= compared / 200, $"{outside} of {compared} entries are over the tight Richardson tolerance, more than 0.5%");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnoiseHessianIsSymmetric()
|
||||
{
|
||||
var random = new System.Random(0x5A13);
|
||||
var points = Enumerable.Range(0, 20_000).Select(_ => new float3(random.NextSingle() * 512.0f - 256.0f, random.NextSingle() * 512.0f - 256.0f, random.NextSingle() * 512.0f - 256.0f))
|
||||
.Concat(TiePoints()).Append(new float3(0.0f, 0.0f, 0.0f)).ToList();
|
||||
foreach (var p in points)
|
||||
{
|
||||
var h = snoise_hessian(p);
|
||||
Assert.True(BitConverter.SingleToInt32Bits(h._m01) == BitConverter.SingleToInt32Bits(h._m10)
|
||||
&& BitConverter.SingleToInt32Bits(h._m02) == BitConverter.SingleToInt32Bits(h._m20)
|
||||
&& BitConverter.SingleToInt32Bits(h._m12) == BitConverter.SingleToInt32Bits(h._m21), $"snoise_hessian({p}) is not its own transpose: {h}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnoiseHessianContinuousOnSimplexDiagonal()
|
||||
{
|
||||
// Within 1 or 2 ulp of a tie point along each axis the Hessian moves by at most the third derivative
|
||||
// bound times the distance: SNOISE_THIRD x 2 sqrt 3 x 1e-4 covers a 1e-4 ball, and the points are
|
||||
// far closer than that. The 0.3.0 cyclic tie order is a jump of order one here.
|
||||
var bound = SNOISE_THIRD * 2.0 * Math.Sqrt(3.0) * 1.0e-4;
|
||||
var worst = 0.0;
|
||||
foreach (var p in TiePoints())
|
||||
{
|
||||
var centre = snoise_hessian(p);
|
||||
for (var axis = 0; axis < 3; axis++)
|
||||
{
|
||||
for (var direction = -1; direction <= 1; direction += 2)
|
||||
{
|
||||
var q = p;
|
||||
for (var ulp = 1; ulp <= 2; ulp++)
|
||||
{
|
||||
var component = axis == 0 ? q.x : axis == 1 ? q.y : q.z;
|
||||
component = direction < 0 ? MathF.BitDecrement(component) : MathF.BitIncrement(component);
|
||||
q = axis == 0 ? new float3(component, q.y, q.z) : axis == 1 ? new float3(q.x, component, q.z) : new float3(q.x, q.y, component);
|
||||
var moved = snoise_hessian(q);
|
||||
for (var i = 0; i < 3; i++)
|
||||
for (var j = 0; j < 3; j++)
|
||||
{
|
||||
var jump = Math.Abs(moved[i][j] - (double)centre[i][j]);
|
||||
worst = Math.Max(worst, jump);
|
||||
Assert.True(jump <= bound, $"snoise_hessian jumps {jump:R} in entry [{i}][{j}] within {ulp} ulp of {p}, over {bound:R}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output.WriteLine($"largest Hessian jump within 2 ulp of the simplex tie set: {worst:R} against the bound {bound:R}");
|
||||
}
|
||||
|
||||
// Adaptive Simpson of f over [a, b] in double, to an absolute tolerance, with a depth cap.
|
||||
private static double Quadrature(Func<double, double> f, double a, double b, double tolerance)
|
||||
{
|
||||
double Simpson(double fa, double fm, double fb, double width) => width / 6.0 * (fa + 4.0 * fm + fb);
|
||||
|
||||
double Recurse(double x0, double x1, double f0, double fm, double f1, double whole, double eps, int depth)
|
||||
{
|
||||
var mid = 0.5 * (x0 + x1);
|
||||
var lm = 0.5 * (x0 + mid);
|
||||
var rm = 0.5 * (mid + x1);
|
||||
var flm = f(lm);
|
||||
var frm = f(rm);
|
||||
var left = Simpson(f0, flm, fm, mid - x0);
|
||||
var right = Simpson(fm, frm, f1, x1 - mid);
|
||||
if (depth <= 0 || Math.Abs(left + right - whole) <= 15.0 * eps)
|
||||
return left + right + (left + right - whole) / 15.0;
|
||||
return Recurse(x0, mid, f0, flm, fm, left, eps / 2.0, depth - 1) + Recurse(mid, x1, fm, frm, f1, right, eps / 2.0, depth - 1);
|
||||
}
|
||||
|
||||
if (a == b)
|
||||
return 0.0;
|
||||
var fa = f(a);
|
||||
var fb = f(b);
|
||||
var fmid = f(0.5 * (a + b));
|
||||
return Recurse(a, b, fa, fmid, fb, Simpson(fa, fmid, fb, b - a), tolerance, 40);
|
||||
}
|
||||
|
||||
private static double ClampedLine(double a, double b, double lo, double hi, double t) => Math.Min(Math.Max(a + b * t, lo), hi);
|
||||
|
||||
// The reference integral in double: the adaptive quadrature of the clamped line, sign for a reversed span.
|
||||
private static double ReferenceIntegral(float a, float b, float lo, float hi, float t0, float t1)
|
||||
{
|
||||
double low = Math.Min(lo, hi);
|
||||
var integral = t0 <= t1
|
||||
? Quadrature(t => ClampedLine(a, b, low, hi, t), t0, t1, 1.0e-12)
|
||||
: -Quadrature(t => ClampedLine(a, b, low, hi, t), t1, t0, 1.0e-12);
|
||||
return integral;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntegralClampLinearMatchesQuadrature()
|
||||
{
|
||||
var random = new System.Random(0x1A7E);
|
||||
float Uniform(float lo, float hi) => lo + (hi - lo) * random.NextSingle();
|
||||
double worstRatio = 0.0;
|
||||
int zeroSlopes = 0, reversed = 0, both = 0, neither = 0, ramps = 0;
|
||||
for (var k = 0; k < 100_000; k++)
|
||||
{
|
||||
var a = Uniform(-4.0f, 4.0f);
|
||||
// One in eight slopes is zero, one in sixteen is tiny (a crossing far outside any span).
|
||||
var b = (k % 8) switch { 0 => 0.0f, 1 => 1.0e-30f * (k % 16 == 1 ? 1.0f : -1.0f), _ => Uniform(-4.0f, 4.0f) };
|
||||
var lo = Uniform(-2.0f, 0.5f);
|
||||
var hi = lo + Uniform(0.0f, 3.0f);
|
||||
var t0 = Uniform(-3.0f, 3.0f);
|
||||
var t1 = Uniform(-3.0f, 3.0f);
|
||||
var got = integral_clamp_linear(a, b, lo, hi, t0, t1);
|
||||
var want = ReferenceIntegral(a, b, lo, hi, t0, t1);
|
||||
var span = Math.Abs((double)t1 - t0);
|
||||
var error = Math.Abs(got - want);
|
||||
worstRatio = Math.Max(worstRatio, error / Math.Max(1.0e-5 * Math.Abs(want), 1.0e-6 * span));
|
||||
Assert.True(error <= 1.0e-5 * Math.Abs(want) || error <= 1.0e-6 * span, $"integral_clamp_linear({a:R}, {b:R}, {lo:R}, {hi:R}, {t0:R}, {t1:R}) = {got:R}, quadrature {want:R}");
|
||||
if (b == 0.0f)
|
||||
zeroSlopes++;
|
||||
if (t1 < t0)
|
||||
reversed++;
|
||||
if (b != 0.0f)
|
||||
{
|
||||
double atLo = (lo - (double)a) / b, atHi = (hi - (double)a) / b;
|
||||
double from = Math.Min(t0, t1), to = Math.Max(t0, t1);
|
||||
var crossesLo = atLo > from && atLo < to;
|
||||
var crossesHi = atHi > from && atHi < to;
|
||||
if (crossesLo && crossesHi)
|
||||
both++;
|
||||
else if (!crossesLo && !crossesHi)
|
||||
neither++;
|
||||
if (hi > lo && (crossesLo || crossesHi))
|
||||
ramps++;
|
||||
}
|
||||
}
|
||||
|
||||
output.WriteLine($"integral_clamp_linear vs adaptive quadrature over 100000 cases: {zeroSlopes} flat, {reversed} reversed, {both} crossing both bounds, {neither} crossing neither, {ramps} on a ramp; worst error {worstRatio:F3} of its tolerance");
|
||||
Assert.True(zeroSlopes > 10_000 && reversed > 40_000 && both > 2_000 && neither > 2_000, $"the sample meets a branch too rarely: {zeroSlopes} flat, {reversed} reversed, {both} both, {neither} neither");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntegralClampLinearHasExactValuesOnTheBinaryFloats()
|
||||
{
|
||||
// The ramp t from 0 to 1 clamped to [0.25, 0.75] over [0, 1]: 0.25 x 0.25 below, 0.5 x 0.5 on the ramp, 0.75 x 0.25 above.
|
||||
Assert.Equal(0.0625f + 0.25f + 0.1875f, integral_clamp_linear(0.0f, 1.0f, 0.25f, 0.75f, 0.0f, 1.0f));
|
||||
// A reversed span is the negative of the span.
|
||||
Assert.Equal(-0.5f, integral_clamp_linear(0.0f, 1.0f, 0.25f, 0.75f, 1.0f, 0.0f));
|
||||
Assert.Equal(integral_clamp_linear(0.3f, -0.7f, -0.5f, 0.9f, -2.0f, 1.5f), -integral_clamp_linear(0.3f, -0.7f, -0.5f, 0.9f, 1.5f, -2.0f));
|
||||
// b = 0 is the constant, clamped.
|
||||
Assert.Equal(2.0f * 3.0f, integral_clamp_linear(5.0f, 0.0f, 0.0f, 2.0f, 1.0f, 4.0f));
|
||||
Assert.Equal(-1.0f * 3.0f, integral_clamp_linear(-5.0f, 0.0f, -1.0f, 2.0f, 1.0f, 4.0f));
|
||||
// An empty span, and a span that never leaves the ramp (the trapezoid: 3 x mean of 1 and 4).
|
||||
Assert.Equal(0.0f, integral_clamp_linear(0.0f, 1.0f, -9.0f, 9.0f, 2.0f, 2.0f));
|
||||
Assert.Equal(7.5f, integral_clamp_linear(0.0f, 1.0f, -9.0f, 9.0f, 1.0f, 4.0f));
|
||||
// A falling line starts at the upper bound: 1 for t in [0, 1], the ramp 1 to 0 over [1, 2], 0 after.
|
||||
Assert.Equal(1.0f + 0.5f, integral_clamp_linear(2.0f, -1.0f, 0.0f, 1.0f, 0.0f, 3.0f));
|
||||
// lo above hi clamps to hi everywhere.
|
||||
Assert.Equal(2.0f * 3.0f, integral_clamp_linear(0.0f, 1.0f, 5.0f, 2.0f, 0.0f, 3.0f));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,75 @@
|
|||
{
|
||||
"oracle": "tools/cycles-oracle/oracle.py (Blender 5.2.2 LTS, build d13f752e3b9c, Cycles CPU, 1 sample, Emit bake, float Non-Color images)",
|
||||
"blender": "5.2.2 LTS",
|
||||
"build_hash": "d13f752e3b9c",
|
||||
"platform": "Linux",
|
||||
"encoding": "each case is 'operands -> results', every scalar a float32 bit pattern in hex, colours flattened as r g b",
|
||||
"family": "infinite",
|
||||
"functions": [
|
||||
{
|
||||
"function": "cycles_power",
|
||||
"node": "ShaderNodeMath POWER",
|
||||
"cases": [
|
||||
"bf800000 inf -> 3f800000",
|
||||
"bf800000 -inf -> 3f800000",
|
||||
"c0000000 inf -> inf",
|
||||
"c0000000 -inf -> 00000000",
|
||||
"bf000000 inf -> 00000000",
|
||||
"bf000000 -inf -> inf",
|
||||
"80000000 inf -> 00000000",
|
||||
"80000000 -inf -> 00000000",
|
||||
"3f000000 inf -> 00000000",
|
||||
"3f000000 -inf -> inf",
|
||||
"40000000 inf -> inf",
|
||||
"40000000 -inf -> 00000000",
|
||||
"3f800000 inf -> 3f800000",
|
||||
"00000000 inf -> 00000000",
|
||||
"00000000 -inf -> 00000000",
|
||||
"inf 40400000 -> inf",
|
||||
"-inf 40400000 -> -inf",
|
||||
"-inf 40000000 -> inf",
|
||||
"inf bf800000 -> 00000000",
|
||||
"-inf bf800000 -> 00000000",
|
||||
"-inf inf -> inf",
|
||||
"inf inf -> inf"
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "cycles_modulo",
|
||||
"node": "ShaderNodeMath MODULO",
|
||||
"cases": [
|
||||
"inf 3f800000 -> nan",
|
||||
"-inf 3f800000 -> nan",
|
||||
"inf 40400000 -> nan",
|
||||
"-inf 40400000 -> nan",
|
||||
"inf 3dcccccd -> nan",
|
||||
"-inf 3dcccccd -> nan",
|
||||
"inf c0000000 -> nan",
|
||||
"40a00000 inf -> 40a00000",
|
||||
"c0a00000 inf -> c0a00000",
|
||||
"40a00000 -inf -> 40a00000",
|
||||
"inf inf -> nan",
|
||||
"-inf inf -> nan",
|
||||
"00000000 inf -> 00000000",
|
||||
"inf 00000000 -> 00000000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "cycles_map_range_smoothstep",
|
||||
"node": "ShaderNodeMapRange SMOOTHSTEP",
|
||||
"cases": [
|
||||
"-inf -inf 3f800000 40000000 40400000 -> nan",
|
||||
"-inf 3f800000 -inf 40000000 40400000 -> nan",
|
||||
"-inf 3f800000 40a00000 40000000 40400000 -> 40000000",
|
||||
"-inf c0a00000 3f800000 40000000 40400000 -> 40000000",
|
||||
"inf c0a00000 3f800000 40000000 40400000 -> 40400000",
|
||||
"inf 3f800000 c0a00000 40000000 40400000 -> 40000000",
|
||||
"inf inf 3f800000 40000000 40400000 -> 40000000",
|
||||
"inf 3f800000 inf 40000000 40400000 -> 40400000",
|
||||
"-inf inf 3f800000 40000000 40400000 -> 40400000",
|
||||
"-inf 3f800000 inf 40000000 40400000 -> 40000000",
|
||||
"3f800000 -inf inf 40000000 40400000 -> nan"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
20610
packages/cultmath/tests/CultMath.Tests/fixtures/cycles-oracle/math.json
Normal file
20610
packages/cultmath/tests/CultMath.Tests/fixtures/cycles-oracle/math.json
Normal file
File diff suppressed because it is too large
Load diff
21640
packages/cultmath/tests/CultMath.Tests/fixtures/cycles-oracle/mix.json
Normal file
21640
packages/cultmath/tests/CultMath.Tests/fixtures/cycles-oracle/mix.json
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,9 @@ param(
|
|||
# Also concatenate shaders\CultMath.Phacelle.glsl (MPL-2.0) after CultMath.glsl, as a consumer that
|
||||
# calls cultmath_phacelle does. Without it the wrapper is the MIT library alone.
|
||||
[switch]$Phacelle,
|
||||
# Also concatenate shaders\CultMath.Cycles.glsl (Apache-2.0), as a consumer that calls a cultmath_cycles_
|
||||
# function does.
|
||||
[switch]$Cycles,
|
||||
[string]$GlslangPath = ""
|
||||
)
|
||||
|
||||
|
|
@ -23,6 +26,9 @@ $library = Get-Content -LiteralPath (Join-Path $repoRoot "shaders\CultMath.glsl"
|
|||
if ($Phacelle) {
|
||||
$library += Get-Content -LiteralPath (Join-Path $repoRoot "shaders\CultMath.Phacelle.glsl") -Raw
|
||||
}
|
||||
if ($Cycles) {
|
||||
$library += Get-Content -LiteralPath (Join-Path $repoRoot "shaders\CultMath.Cycles.glsl") -Raw
|
||||
}
|
||||
$calls = foreach ($match in [regex]::Matches($library, '(?m)^\w+\s+(cultmath_\w+)\s*\(([^)]*)\)')) {
|
||||
$arguments = foreach ($parameter in ($match.Groups[2].Value -split ',')) {
|
||||
$type = ($parameter.Trim() -split '\s+')[0]
|
||||
|
|
|
|||
70
packages/cultmath/tools/cycles-oracle/README.md
Normal file
70
packages/cultmath/tools/cycles-oracle/README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Cycles oracle
|
||||
|
||||
`oracle.py` produces the fixtures that `CyclesOracleTests` holds `math.cycles_*` to: for each Cycles node
|
||||
CultMath ports, the node's results as Blender's own Cycles computes them.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
CYCLES_CPU_NO_AVX2=1 blender --background --factory-startup --python oracle.py -- <fixture directory> [<family> ...]
|
||||
```
|
||||
|
||||
The fixture directory is `tests/CultMath.Tests/fixtures/cycles-oracle`. Families are `math`, `map-range`,
|
||||
`clamp`, `mix`, `rgb-to-bw` and `ramp`; with none named, all are written. The tool is a Blender Python
|
||||
script and runs only under Blender, never under a plain Python.
|
||||
|
||||
## The pin
|
||||
|
||||
It refuses to run under any Blender but **5.2.2 LTS, build hash `d13f752e3b9c`**, exits nonzero and names
|
||||
the version and build it found beside the ones it needs. Blender 5.2.2 is
|
||||
<https://download.blender.org/release/Blender5.2/> (`blender-5.2.2-linux-x64.tar.xz`). The platform is Linux x64: the fixtures reproduce
|
||||
byte for byte only there, and the Steam/Windows build is another build (see below). The fixtures record the
|
||||
version, the hash and the platform they were written on, as provenance.
|
||||
|
||||
## The reference and other builds
|
||||
|
||||
Ruling `cultmath-tapes:ruling:cycles-kernel-reference-ruled`: CultMath's Cycles converters mean Cycles' formulas
|
||||
evaluated with IEEE single rounding per operation, and the Linux SSE4.2 oracle stays their bit-exact witness.
|
||||
Every other Cycles build (AVX2 FMA-contracted, MSVC /fp:fast, GPU backends) and CultMath's own HLSL lowering are
|
||||
held to a stated per-function tolerance class measured on that build; cancellation-prone functions (floored
|
||||
modulo, wrap) get an error bound relative to |b| and a documented divergence note.
|
||||
|
||||
Cycles' AVX2 CPU kernel is compiled with fused multiply-add, so `a - floor(a / b) b` is one fused operation
|
||||
there, and CultMath's C# (which fuses nothing) computes the unfused one. The fixtures pin the SSE4.2 kernel,
|
||||
which has no FMA, by `CYCLES_CPU_NO_AVX2=1`; the tool bakes `10000 mod 0.1` before anything else and exits 4 if
|
||||
the result is the fused one, so a run on the wrong kernel writes nothing. Other kernels are other builds:
|
||||
measured under the table below, never written to these fixtures.
|
||||
|
||||
| build | how it was measured | fixture | per-function ulp or bound |
|
||||
| --- | --- | --- | --- |
|
||||
| Blender 5.2.2 Linux x64, SSE4.2 kernel (the reference) | `oracle.py` with `CYCLES_CPU_NO_AVX2=1`; `CyclesOracleTests` holds every function to the fixtures | `tests/CultMath.Tests/fixtures/cycles-oracle/*.json` | 0 ulp, every function |
|
||||
| Blender 5.2.2 Linux x64, AVX2 kernel | the same oracle without the pin, 1024 rows per function, against the single-rounding model `fma(-floor(a / b), b, a)` (wrap: `fma(-range, floor((value - min) / range), value)`) | none committed | floored modulo: at most 2^-23 (\|a / b\| + 1) \|b\|, measured 0.91 of 2^-24 (\|a\| + \|b\|) on 151 rows; wrap: at most 2^-23 (\|value\| + \|min\| + \|range\|), measured 0.77 of 2^-24 on 30 rows; other functions unmeasured |
|
||||
|
||||
The divergence note: a build compiled with fast-math may also move `floor(a / b)` by one near an integer
|
||||
quotient, so the result differs by a whole \|b\| (wrap: a whole range). Further rows come from follow-up
|
||||
`cultmath-tapes:follow_up:cycles-tolerance-classes`; its per-build fixtures go under
|
||||
`fixtures/cycles-oracle/<build>/` with their tolerances beside `Tolerances` in `CyclesOracleTests`.
|
||||
|
||||
## Method
|
||||
|
||||
Operands go into float, Non-Color input images (channel packed, read with Closest interpolation), three
|
||||
operands per image. The node under test reads them. One Emit bake per output channel (Cycles on the CPU, 1
|
||||
sample, margin 0, no denoising) writes the result to a float image; each pixel is one case. The tool first
|
||||
checks that a bake returns an operand image bit for bit, and stops if it does not. The fixtures hold the
|
||||
operands and the results as float32 bit patterns, so a test reads exactly what Cycles computed.
|
||||
|
||||
A Map Range node's Clamp option is baked as the node with Clamp on, which Cycles expands into a Clamp node
|
||||
of type Range on the result. The ramp family bakes a Color Ramp whose table entries are each read at a `t`
|
||||
whose product with 255 is the entry's index, so the first 256 cases are the ramp's own table.
|
||||
|
||||
## Fixtures
|
||||
|
||||
Written by this tool and never edited by hand. Rerunning it under the same Blender build on the same
|
||||
platform rewrites them byte for byte; `CyclesOracleTests` holds every function to them at 0 ulp. Run the
|
||||
oracle on Yggdrasil-class Linux, never on the workstation.
|
||||
|
||||
## Licence
|
||||
|
||||
`oracle.py` is GPL-3.0-or-later, as a script that runs inside Blender (`bpy`). It ships in no package: the
|
||||
`GameCult.Math` NuGet package and the Unity package do not contain `tools/`. The fixtures are data it
|
||||
produced. The Cycles functions they test are Apache-2.0 (`math.Cycles.cs`, `CultMath.Cycles.hlsl`).
|
||||
699
packages/cultmath/tools/cycles-oracle/oracle.py
Normal file
699
packages/cultmath/tools/cycles-oracle/oracle.py
Normal file
|
|
@ -0,0 +1,699 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# The Cycles oracle for CultMath's Cycles node semantics (math.Cycles.cs, shaders/CultMath.Cycles.hlsl).
|
||||
# A Blender Python script, so it is GPL-3.0-or-later; it is a test tool and ships in no package.
|
||||
#
|
||||
# blender --background --factory-startup --python oracle.py -- <fixture directory> [<family> ...]
|
||||
#
|
||||
# It refuses to run under any Blender but 5.2.2 LTS, build hash d13f752e3b9c. For each family it bakes
|
||||
# the node under test with Cycles on the CPU and writes tests/CultMath.Tests/fixtures/cycles-oracle/<family>.json.
|
||||
# Method: operands go into float, Non-Color input images read with Closest interpolation; one Emit bake per
|
||||
# output channel (1 sample, margin 0, no denoising) writes the node's result to a float image whose pixels are
|
||||
# the results, bit for bit. The fixtures are this tool's output and are never edited by hand. They witness the
|
||||
# reference of ruling cultmath-tapes:ruling:cycles-kernel-reference-ruled (one IEEE single rounding per operation,
|
||||
# the SSE4.2 kernel, CYCLES_CPU_NO_AVX2=1); other kernels are other builds and are never written here.
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
PINNED_VERSION = "5.2.2 LTS"
|
||||
PINNED_HASH = "d13f752e3b9c"
|
||||
SIDE = 32
|
||||
CASES = SIDE * SIDE
|
||||
|
||||
|
||||
def refuse_other_blender():
|
||||
found = (bpy.app.version_string, bpy.app.build_hash.decode() if isinstance(bpy.app.build_hash, bytes) else bpy.app.build_hash)
|
||||
if found != (PINNED_VERSION, PINNED_HASH):
|
||||
sys.stderr.write(
|
||||
"cycles-oracle: needs Blender %s build %s, found %s build %s\n" % (PINNED_VERSION, PINNED_HASH, found[0], found[1]))
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
refuse_other_blender()
|
||||
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "CYCLES"
|
||||
scene.cycles.device = "CPU"
|
||||
scene.cycles.samples = 1
|
||||
scene.cycles.use_denoising = False
|
||||
bpy.ops.mesh.primitive_plane_add(size=2)
|
||||
PLANE = bpy.context.active_object
|
||||
|
||||
|
||||
def by_identifier(sockets, identifier):
|
||||
"""A node's socket by identifier: several data types share one socket name."""
|
||||
for socket in sockets:
|
||||
if socket.identifier == identifier:
|
||||
return socket
|
||||
raise KeyError("no socket %s among %s" % (identifier, [s.identifier for s in sockets]))
|
||||
|
||||
|
||||
def f32(values):
|
||||
return np.asarray(values, dtype=np.float32)
|
||||
|
||||
|
||||
def bits(value):
|
||||
return "%08x" % int(np.asarray(value, dtype=np.float32).view(np.uint32))
|
||||
|
||||
|
||||
def rng_for(name):
|
||||
# One stream per name: a function, a blend mode's second colours (name + "2"), a ramp. Sharing a seed shares the draws.
|
||||
return np.random.default_rng(int.from_bytes(hashlib.sha256(name.encode()).digest()[:8], "little"))
|
||||
|
||||
|
||||
def new_image(name, rgba=None):
|
||||
image = bpy.data.images.new(name, SIDE, SIDE, alpha=True, float_buffer=True)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
image.alpha_mode = "CHANNEL_PACKED"
|
||||
if rgba is not None:
|
||||
image.pixels.foreach_set(rgba.reshape(-1))
|
||||
image.pack()
|
||||
return image
|
||||
|
||||
|
||||
def pack_operands(columns):
|
||||
"""Operand columns (CASES float32 each) to input images of three operands per image."""
|
||||
images = []
|
||||
for first in range(0, len(columns), 3):
|
||||
rgba = np.zeros((SIDE * SIDE, 4), dtype=np.float32)
|
||||
rgba[:, 3] = 1.0
|
||||
for k, column in enumerate(columns[first:first + 3]):
|
||||
rgba[:, k] = column
|
||||
images.append(new_image("operands%d" % first, rgba.reshape(SIDE, SIDE, 4)))
|
||||
return images
|
||||
|
||||
|
||||
def operand_sockets(tree, images, count):
|
||||
"""One float socket per operand, read from the input images with Closest interpolation."""
|
||||
sockets = []
|
||||
for image in images:
|
||||
texture = tree.nodes.new("ShaderNodeTexImage")
|
||||
texture.image = image
|
||||
texture.interpolation = "Closest"
|
||||
separate = tree.nodes.new("ShaderNodeSeparateColor")
|
||||
tree.links.new(texture.outputs["Color"], separate.inputs[0])
|
||||
sockets.append([separate.outputs[0], separate.outputs[1], separate.outputs[2]])
|
||||
flat = [socket for triple in sockets for socket in triple]
|
||||
return flat[:count]
|
||||
|
||||
|
||||
def triple_socket(tree, images, first):
|
||||
"""The colour made of operands first, first + 1, first + 2."""
|
||||
combine = tree.nodes.new("ShaderNodeCombineColor")
|
||||
sockets = operand_sockets(tree, images, first + 3)
|
||||
for k in range(3):
|
||||
tree.links.new(sockets[first + k], combine.inputs[k])
|
||||
return combine.outputs[0]
|
||||
|
||||
|
||||
def bake(label, images, build, channels):
|
||||
"""Bakes build(tree, images) once per output channel; returns channels float32 arrays of CASES results."""
|
||||
results = []
|
||||
for channel in range(channels):
|
||||
material = bpy.data.materials.new("%s.%d" % (label, channel))
|
||||
material.use_nodes = True
|
||||
tree = material.node_tree
|
||||
tree.nodes.clear()
|
||||
output = tree.nodes.new("ShaderNodeOutputMaterial")
|
||||
emit = tree.nodes.new("ShaderNodeEmission")
|
||||
tree.links.new(emit.outputs[0], output.inputs[0])
|
||||
sockets = build(tree, images)
|
||||
socket = sockets[channel]
|
||||
if socket.type == "RGBA" or socket.type == "VECTOR":
|
||||
separate = tree.nodes.new("ShaderNodeSeparateColor")
|
||||
tree.links.new(socket, separate.inputs[0])
|
||||
# Cycles stores a colour result channel by channel; the channel under test is red.
|
||||
tree.links.new(separate.outputs[0], emit.inputs["Color"])
|
||||
else:
|
||||
tree.links.new(socket, emit.inputs["Color"])
|
||||
target = new_image("%s.%d.out" % (label, channel))
|
||||
target_node = tree.nodes.new("ShaderNodeTexImage")
|
||||
target_node.image = target
|
||||
tree.nodes.active = target_node
|
||||
PLANE.data.materials.clear()
|
||||
PLANE.data.materials.append(material)
|
||||
bpy.context.view_layer.objects.active = PLANE
|
||||
PLANE.select_set(True)
|
||||
bpy.ops.object.bake(type="EMIT", margin=0)
|
||||
pixels = np.empty(SIDE * SIDE * 4, dtype=np.float32)
|
||||
target.pixels.foreach_get(pixels)
|
||||
results.append(pixels.reshape(SIDE * SIDE, 4)[:, 0].copy())
|
||||
return results
|
||||
|
||||
|
||||
def vector_bake(label, images, build):
|
||||
"""For a node whose result is a colour: one bake per RGB channel via Separate Color."""
|
||||
results = []
|
||||
for channel in range(3):
|
||||
def pick(tree, imgs, channel=channel):
|
||||
color = build(tree, imgs)
|
||||
separate = tree.nodes.new("ShaderNodeSeparateColor")
|
||||
tree.links.new(color, separate.inputs[0])
|
||||
return [separate.outputs[channel]]
|
||||
results.append(bake("%s.c%d" % (label, channel), images, pick, 1)[0])
|
||||
return results
|
||||
|
||||
|
||||
# ---- operand sets ----
|
||||
|
||||
SPECIAL = [0.0, 1.0, -1.0, 0.5, -0.5, 2.0, -2.0, 3.0, -2.75, 0.1, -0.1, 7.5, -7.5, 1.0e-7, -1.0e-7, 100.0, -100.0, 1.0e4, 0.3, 0.7]
|
||||
POSITIVE = [0.0, 1.0, 0.5, 2.0, 3.0, 0.1, 10.0, 7.5, 1.0e-3, 100.0, 0.3, 0.7, 2.718281828]
|
||||
FACTOR = [0.0, 0.5, 1.0, 1.5, -0.5, 0.25, 0.75, 2.0]
|
||||
COLOR_SPECIAL = [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, -0.25, 0.1, 0.9, 0.3333333, 0.6666667]
|
||||
|
||||
|
||||
def sample_columns(name, count, specials, lows_highs, pair_specials=2):
|
||||
"""CASES rows of `count` operands: the specials' product for the first operands, then uniform draws."""
|
||||
rng = rng_for(name)
|
||||
rows = []
|
||||
import itertools
|
||||
lists = [specials[k] if k < len(specials) else None for k in range(count)]
|
||||
fixed = [lst for lst in lists if lst is not None]
|
||||
if fixed:
|
||||
for combo in itertools.product(*fixed):
|
||||
row = list(combo) + [None] * (count - len(combo))
|
||||
rows.append(row)
|
||||
rows = rows[:CASES]
|
||||
while len(rows) < CASES:
|
||||
rows.append([None] * count)
|
||||
columns = []
|
||||
for k in range(count):
|
||||
low, high = lows_highs[k]
|
||||
column = np.empty(CASES, dtype=np.float32)
|
||||
for r, row in enumerate(rows):
|
||||
column[r] = row[k] if row[k] is not None else rng.uniform(low, high)
|
||||
columns.append(column)
|
||||
return columns
|
||||
|
||||
|
||||
def random_color_rows(name, extra=0):
|
||||
"""CASES colours: primaries, secondaries, greys, the specials' products, then random with tied channels."""
|
||||
rng = rng_for(name)
|
||||
rows = []
|
||||
for r in COLOR_SPECIAL[:5]:
|
||||
for g in COLOR_SPECIAL[:5]:
|
||||
for b in COLOR_SPECIAL[:5]:
|
||||
rows.append((r, g, b))
|
||||
for v in COLOR_SPECIAL:
|
||||
rows.append((v, v, v))
|
||||
while len(rows) < CASES:
|
||||
c = list(rng.uniform(-0.25, 1.25, 3))
|
||||
tie = rng.integers(0, 6)
|
||||
if tie == 0:
|
||||
c[1] = c[0]
|
||||
elif tie == 1:
|
||||
c[2] = c[1]
|
||||
elif tie == 2:
|
||||
c[2] = c[0]
|
||||
rows.append(tuple(c))
|
||||
return rows[:CASES]
|
||||
|
||||
|
||||
# ---- families ----
|
||||
|
||||
MATH = [
|
||||
# (function, operation, operand count, operand specials, ranges)
|
||||
("cycles_divide", "DIVIDE", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
|
||||
("cycles_power", "POWER", 2, [SPECIAL, SPECIAL], [(-4, 4), (-4, 4)]),
|
||||
("cycles_logarithm", "LOGARITHM", 2, [SPECIAL, SPECIAL], [(0.01, 20), (0.01, 20)]),
|
||||
("cycles_sqrt", "SQRT", 1, [SPECIAL], [(-5, 20)]),
|
||||
("cycles_inverse_sqrt", "INVERSE_SQRT", 1, [SPECIAL], [(-5, 20)]),
|
||||
("cycles_modulo", "MODULO", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
|
||||
("cycles_floored_modulo", "FLOORED_MODULO", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
|
||||
("cycles_wrap", "WRAP", 3, [SPECIAL[:10], SPECIAL[:10], SPECIAL[:10]], [(-20, 20), (-10, 10), (-10, 10)]),
|
||||
("cycles_snap", "SNAP", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
|
||||
("cycles_pingpong", "PINGPONG", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
|
||||
("cycles_smooth_min", "SMOOTH_MIN", 3, [SPECIAL[:10], SPECIAL[:10], SPECIAL[:10]], [(-20, 20), (-20, 20), (-3, 3)]),
|
||||
("cycles_smooth_max", "SMOOTH_MAX", 3, [SPECIAL[:10], SPECIAL[:10], SPECIAL[:10]], [(-20, 20), (-20, 20), (-3, 3)]),
|
||||
("cycles_compare", "COMPARE", 3, [SPECIAL[:10], SPECIAL[:10], [0.0, 0.5, 1.0, 1.0e-7]], [(-20, 20), (-20, 20), (0, 2)]),
|
||||
("cycles_round", "ROUND", 1, [SPECIAL], [(-20, 20)]),
|
||||
("cycles_trunc", "TRUNC", 1, [SPECIAL], [(-20, 20)]),
|
||||
("cycles_fract", "FRACT", 1, [SPECIAL], [(-20, 20)]),
|
||||
("cycles_sign", "SIGN", 1, [SPECIAL], [(-20, 20)]),
|
||||
("cycles_arcsine", "ARCSINE", 1, [SPECIAL], [(-1.5, 1.5)]),
|
||||
("cycles_arccosine", "ARCCOSINE", 1, [SPECIAL], [(-1.5, 1.5)]),
|
||||
("cycles_arctan2", "ARCTAN2", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
|
||||
]
|
||||
|
||||
# Operands that must be present as exact pairs whatever the random draws: the quirks the port names.
|
||||
EXTRA_ROWS = {
|
||||
"cycles_power": [(-2.0, 1.0e10), (-2.0, -1.0e10), (-2.0, 3.0), (-2.0, 2.0), (-2.0, -3.0), (-2.0, 2147483648.0), (-2.0, -2147483648.0),
|
||||
(-8.0, 0.3333333), (2.0, 0.0), (0.0, 0.0), (0.0, 2.0), (-0.0, -3.0), (-1.5, 17.0), (-1.5, 16777217.0),
|
||||
# A base of -1 keeps a finite result for any exponent, so the film shows the exponent past the int range.
|
||||
(-1.0, 3.0e9), (-1.0, -3.0e9), (-1.0, 2147483648.0), (-1.0, 16777216.0), (-1.0, 16777215.0)],
|
||||
"cycles_compare": [(0.0, 1.0e-7, 0.0), (1.0, 1.0000001, 0.0), (1.0, 1.0000002, 0.0), (0.0, 1.0e-7, 1.0e-8), (5.0, 5.0, 0.0), (0.0, 2.0e-7, 1.0e-7)],
|
||||
# Burn with a divisor of exactly 0 (tm + t col2 = 0) and a colour above 1: the result is 1 for the channel Cycles reads
|
||||
# as 0, and an infinity the film would hide for the others.
|
||||
"cycles_mix_burn": [(2.0, 1.5, 0.5, 2.5, 0.5, 0.5, 0.5), (0.5, 1.5, 2.0, 3.0, -1.0, -1.0, -1.0)],
|
||||
# safe_asinf / safe_acosf clamp to [-1, 1]; compatible_atan2 guards only the origin (y = a, x = b).
|
||||
"cycles_arcsine": [(2.0,), (-2.0,), (1.0,), (-1.0,), (1.0000001,), (-1.0000001,), (1.5,)],
|
||||
"cycles_arccosine": [(2.0,), (-2.0,), (1.0,), (-1.0,), (1.0000001,), (-1.0000001,), (1.5,)],
|
||||
"cycles_arctan2": [(0.0, 0.0), (0.0, -0.0), (-0.0, -0.0), (-0.0, -1.0), (0.0, -1.0), (1.0, 0.0), (-1.0, 0.0), (1.0e-7, -1.0e-7)],
|
||||
"cycles_modulo": [(0.3, 0.1), (-0.3, 0.1), (5.5, -2.0), (-5.5, -2.0), (1.0, 0.0), (1.0e7, 3.0), (7.0, 7.0), (-7.0, 7.0)],
|
||||
}
|
||||
|
||||
MAP_RANGE = [
|
||||
("cycles_map_range_linear", "LINEAR"),
|
||||
("cycles_map_range_stepped", "STEPPED"),
|
||||
("cycles_map_range_smoothstep", "SMOOTHSTEP"),
|
||||
("cycles_map_range_smootherstep", "SMOOTHERSTEP"),
|
||||
]
|
||||
|
||||
MIX_MODES = [
|
||||
("cycles_mix_blend", "MIX"), ("cycles_mix_add", "ADD"), ("cycles_mix_mul", "MULTIPLY"), ("cycles_mix_screen", "SCREEN"),
|
||||
("cycles_mix_overlay", "OVERLAY"), ("cycles_mix_sub", "SUBTRACT"), ("cycles_mix_div", "DIVIDE"), ("cycles_mix_diff", "DIFFERENCE"),
|
||||
("cycles_mix_exclusion", "EXCLUSION"), ("cycles_mix_dark", "DARKEN"), ("cycles_mix_light", "LIGHTEN"), ("cycles_mix_dodge", "DODGE"),
|
||||
("cycles_mix_burn", "BURN"), ("cycles_mix_hue", "HUE"), ("cycles_mix_sat", "SATURATION"), ("cycles_mix_val", "VALUE"),
|
||||
("cycles_mix_color", "COLOR"), ("cycles_mix_soft", "SOFT_LIGHT"), ("cycles_mix_linear", "LINEAR_LIGHT"),
|
||||
]
|
||||
|
||||
|
||||
def with_extra(function, columns, count):
|
||||
extra = EXTRA_ROWS.get(function, [])
|
||||
for r, row in enumerate(extra):
|
||||
for k in range(count):
|
||||
columns[k][CASES - 1 - r] = row[k] if k < len(row) else 0.0
|
||||
return columns
|
||||
|
||||
|
||||
def encode_cases(columns, results):
|
||||
cases = []
|
||||
for r in range(CASES):
|
||||
cases.append("%s -> %s" % (" ".join(bits(c[r]) for c in columns), " ".join(bits(x[r]) for x in results)))
|
||||
return cases
|
||||
|
||||
|
||||
def family_math():
|
||||
functions = []
|
||||
for function, operation, count, specials, ranges in MATH:
|
||||
columns = with_extra(function, sample_columns(function, count, specials, ranges), count)
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build(tree, imgs, operation=operation, count=count):
|
||||
node = tree.nodes.new("ShaderNodeMath")
|
||||
node.operation = operation
|
||||
sockets = operand_sockets(tree, imgs, count)
|
||||
for k in range(count):
|
||||
tree.links.new(sockets[k], node.inputs[k])
|
||||
return [node.outputs[0]]
|
||||
|
||||
functions.append({"function": function, "node": "ShaderNodeMath " + operation, "cases": encode_cases(columns, bake(function, images, build, 1))})
|
||||
return functions
|
||||
|
||||
|
||||
def family_map_range():
|
||||
functions = []
|
||||
for function, kind in MAP_RANGE:
|
||||
count = 6
|
||||
specials = [SPECIAL[:10], SPECIAL[:5], SPECIAL[:5]]
|
||||
columns = sample_columns(function, count, specials, [(-20, 20), (-10, 10), (-10, 10), (-20, 20), (-20, 20), (-1, 8)])
|
||||
# Rows with From Min equal to From Max, and with From Min above From Max, whatever the draws.
|
||||
for r in range(8):
|
||||
columns[2][CASES - 1 - r] = columns[1][CASES - 1 - r]
|
||||
for r in range(8, 16):
|
||||
columns[1][CASES - 1 - r], columns[2][CASES - 1 - r] = max(columns[1][CASES - 1 - r], columns[2][CASES - 1 - r]), min(columns[1][CASES - 1 - r], columns[2][CASES - 1 - r])
|
||||
for r in range(16, 24):
|
||||
columns[5][CASES - 1 - r] = float(r - 18)
|
||||
if kind == "SMOOTHSTEP":
|
||||
# An overflowing range: from_max - from_min is infinite, and Cycles' smoothstep decides x >= edge1 before dividing.
|
||||
for r, row in ((24, (3.0e38, -3.0e38, 3.0e38, 0.0, 1.0, 4.0)), (25, (3.0e38, 3.0e38, -3.0e38, 0.0, 1.0, 4.0))):
|
||||
for k in range(6):
|
||||
columns[k][CASES - 1 - r] = row[k]
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build(tree, imgs, kind=kind):
|
||||
node = tree.nodes.new("ShaderNodeMapRange")
|
||||
node.data_type = "FLOAT"
|
||||
node.interpolation_type = kind
|
||||
node.clamp = False
|
||||
sockets = operand_sockets(tree, imgs, 6)
|
||||
for k, identifier in enumerate(["Value", "From Min", "From Max", "To Min", "To Max", "Steps"]):
|
||||
socket = by_identifier(node.inputs, identifier)
|
||||
tree.links.new(sockets[k], socket)
|
||||
return [node.outputs["Result"]]
|
||||
|
||||
functions.append({"function": function, "node": "ShaderNodeMapRange " + kind, "cases": encode_cases(columns, bake(function, images, build, 1))})
|
||||
# The Clamp option, on the same operands: a Clamp node of type Range on the result.
|
||||
def build_clamped(tree, imgs, kind=kind):
|
||||
node = tree.nodes.new("ShaderNodeMapRange")
|
||||
node.data_type = "FLOAT"
|
||||
node.interpolation_type = kind
|
||||
node.clamp = True
|
||||
sockets = operand_sockets(tree, imgs, 6)
|
||||
for k, identifier in enumerate(["Value", "From Min", "From Max", "To Min", "To Max", "Steps"]):
|
||||
socket = by_identifier(node.inputs, identifier)
|
||||
tree.links.new(sockets[k], socket)
|
||||
return [node.outputs["Result"]]
|
||||
|
||||
functions.append({"function": function, "node": "ShaderNodeMapRange " + kind + " with Clamp",
|
||||
"then": "cycles_clamp_range(result, to_min, to_max)",
|
||||
"cases": encode_cases(columns, bake(function + ".clamped", images, build_clamped, 1))})
|
||||
return functions
|
||||
|
||||
|
||||
def family_clamp():
|
||||
functions = []
|
||||
for function, clamp_type in (("cycles_clamp_minmax", "MINMAX"), ("cycles_clamp_range", "RANGE")):
|
||||
columns = sample_columns(function, 3, [SPECIAL[:10], SPECIAL[:10], SPECIAL[:10]], [(-20, 20)] * 3)
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build(tree, imgs, clamp_type=clamp_type):
|
||||
node = tree.nodes.new("ShaderNodeClamp")
|
||||
node.clamp_type = clamp_type
|
||||
sockets = operand_sockets(tree, imgs, 3)
|
||||
for k in range(3):
|
||||
tree.links.new(sockets[k], node.inputs[k])
|
||||
return [node.outputs[0]]
|
||||
|
||||
functions.append({"function": function, "node": "ShaderNodeClamp " + clamp_type, "cases": encode_cases(columns, bake(function, images, build, 1))})
|
||||
return functions
|
||||
|
||||
|
||||
def mix_columns(name, blend):
|
||||
rng = rng_for(name)
|
||||
colors = random_color_rows(name)
|
||||
colors2 = random_color_rows(name + "2")
|
||||
rng.shuffle(colors2)
|
||||
factors = [FACTOR[r % len(FACTOR)] if r < 8 * 64 else float(rng.uniform(-0.5, 1.5)) for r in range(CASES)]
|
||||
columns = [f32(factors)]
|
||||
columns += [f32([c[k] for c in colors]) for k in range(3)]
|
||||
columns += [f32([c[k] for c in colors2]) for k in range(3)]
|
||||
return columns
|
||||
|
||||
|
||||
def family_mix():
|
||||
functions = []
|
||||
rng = rng_for("mix-float")
|
||||
# Mix, Float: a and b in the usual ranges, the factor as in the blend modes.
|
||||
columns = [f32([FACTOR[r % len(FACTOR)] if r < 512 else rng.uniform(-0.5, 1.5) for r in range(CASES)]),
|
||||
f32(rng.uniform(-20, 20, CASES)), f32(rng.uniform(-20, 20, CASES))]
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build_float(tree, imgs):
|
||||
node = tree.nodes.new("ShaderNodeMix")
|
||||
node.data_type = "FLOAT"
|
||||
node.clamp_factor = False
|
||||
node.clamp_result = False
|
||||
sockets = operand_sockets(tree, imgs, 3)
|
||||
for k, identifier in enumerate(["Factor_Float", "A_Float", "B_Float"]):
|
||||
tree.links.new(sockets[k], by_identifier(node.inputs, identifier))
|
||||
return [by_identifier(node.outputs, "Result_Float")]
|
||||
|
||||
functions.append({"function": "cycles_mix_float", "node": "ShaderNodeMix FLOAT", "cases": encode_cases(columns, bake("mix_float", images, build_float, 1))})
|
||||
|
||||
columns = mix_columns("mix-vector", "VECTOR")
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build_vector(tree, imgs):
|
||||
node = tree.nodes.new("ShaderNodeMix")
|
||||
node.data_type = "VECTOR"
|
||||
node.factor_mode = "UNIFORM"
|
||||
node.clamp_factor = False
|
||||
sockets = operand_sockets(tree, imgs, 1)
|
||||
tree.links.new(sockets[0], by_identifier(node.inputs, "Factor_Float"))
|
||||
tree.links.new(triple_socket(tree, imgs, 1), by_identifier(node.inputs, "A_Vector"))
|
||||
tree.links.new(triple_socket(tree, imgs, 4), by_identifier(node.inputs, "B_Vector"))
|
||||
return by_identifier(node.outputs, "Result_Vector")
|
||||
|
||||
functions.append({"function": "cycles_mix_vector", "node": "ShaderNodeMix VECTOR UNIFORM", "cases": encode_cases(columns, vector_bake("mix_vector", images, build_vector))})
|
||||
|
||||
for function, blend in MIX_MODES:
|
||||
columns = with_extra(function, mix_columns(function, blend), 7)
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build(tree, imgs, blend=blend):
|
||||
node = tree.nodes.new("ShaderNodeMix")
|
||||
node.data_type = "RGBA"
|
||||
node.blend_type = blend
|
||||
node.clamp_factor = False
|
||||
node.clamp_result = False
|
||||
sockets = operand_sockets(tree, imgs, 1)
|
||||
tree.links.new(sockets[0], by_identifier(node.inputs, "Factor_Float"))
|
||||
tree.links.new(triple_socket(tree, imgs, 1), by_identifier(node.inputs, "A_Color"))
|
||||
tree.links.new(triple_socket(tree, imgs, 4), by_identifier(node.inputs, "B_Color"))
|
||||
return by_identifier(node.outputs, "Result_Color")
|
||||
|
||||
functions.append({"function": function, "node": "ShaderNodeMix RGBA " + blend, "cases": encode_cases(columns, vector_bake(function, images, build))})
|
||||
return functions
|
||||
|
||||
|
||||
def family_rgb_to_bw():
|
||||
rows = random_color_rows("rgb_to_bw")
|
||||
columns = [f32([c[k] for c in rows]) for k in range(3)]
|
||||
# One case per primary pins the scene-linear coefficients.
|
||||
for k in range(3):
|
||||
for j in range(3):
|
||||
columns[j][CASES - 1 - k] = 1.0 if j == k else 0.0
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build(tree, imgs):
|
||||
node = tree.nodes.new("ShaderNodeRGBToBW")
|
||||
tree.links.new(triple_socket(tree, imgs, 0), node.inputs["Color"])
|
||||
return [node.outputs["Val"]]
|
||||
|
||||
return [{"function": "cycles_rgb_to_bw", "node": "ShaderNodeRGBToBW", "cases": encode_cases(columns, bake("rgb_to_bw", images, build, 1))}]
|
||||
|
||||
|
||||
# A Blender ramp is sampled at 256 intervals into a table of 257 entries (colorramp_to_array: size + 1 entries
|
||||
# at i / size), and the kernel reads that table with table_size 257.
|
||||
RAMP_SIZE = 257
|
||||
# A constant ramp whose 32 stops sit half an entry past an entry boundary: its table is the stops' values, so
|
||||
# a lookup's result names the entry it read.
|
||||
CONSTANT_STOPS = [((8 * j + 3.5) / (RAMP_SIZE - 1), (j + 1) / 32.0) for j in range(32)]
|
||||
|
||||
|
||||
def ramp_cases(rng, boundaries):
|
||||
"""CASES values of t: the floats around each boundary t = i / 255, the specials, then uniform draws."""
|
||||
values = []
|
||||
for i in boundaries:
|
||||
t = np.float32(i / (RAMP_SIZE - 1))
|
||||
down = [t]
|
||||
up = [t]
|
||||
for _ in range(7):
|
||||
down.append(np.nextafter(down[-1], np.float32(-1.0)))
|
||||
up.append(np.nextafter(up[-1], np.float32(2.0)))
|
||||
values += sorted(set(down + up))
|
||||
values += [-0.5, -1.0e-7, 0.0, 1.0e-7, 1.0, 1.0000001, 1.5]
|
||||
while len(values) < CASES:
|
||||
values.append(float(rng.uniform(-0.25, 1.25)))
|
||||
return f32(values[:CASES])
|
||||
|
||||
|
||||
def ramp_entry(label, interpolation, stops, boundaries):
|
||||
rng = rng_for("ramp-" + label)
|
||||
column = ramp_cases(rng, boundaries)
|
||||
images = pack_operands([column])
|
||||
|
||||
def build(tree, imgs):
|
||||
node = tree.nodes.new("ShaderNodeValToRGB")
|
||||
ramp = node.color_ramp
|
||||
ramp.interpolation = interpolation
|
||||
ramp.elements[0].position = stops[0][0]
|
||||
ramp.elements[1].position = stops[-1][0]
|
||||
for position, _ in stops[1:-1]:
|
||||
ramp.elements.new(position)
|
||||
for element, (_, red) in zip(ramp.elements, stops):
|
||||
element.color = (red, 0.0, 0.0, 1.0)
|
||||
tree.links.new(operand_sockets(tree, imgs, 1)[0], node.inputs["Fac"])
|
||||
return [node.outputs["Color"]]
|
||||
|
||||
return {"function": "cycles_ramp_index", "node": "ShaderNodeValToRGB " + interpolation, "table_entries": RAMP_SIZE,
|
||||
"stops": [[bits(position), bits(red)] for position, red in stops],
|
||||
"cases": encode_cases([column], bake("ramp-" + label, images, build, 1))}
|
||||
|
||||
|
||||
def family_ramp():
|
||||
boundaries = [8 * j + 4 for j in range(32)]
|
||||
return [
|
||||
ramp_entry("constant", "CONSTANT", CONSTANT_STOPS, boundaries),
|
||||
ramp_entry("linear", "LINEAR", [(0.0, 0.0), (1.0, 1.0)], list(range(1, RAMP_SIZE, 17))),
|
||||
]
|
||||
|
||||
|
||||
# ---- infinite operands ----
|
||||
|
||||
INF = float("inf")
|
||||
|
||||
# Rows of (function, node, operands) whose operands may be an infinity. The film writes 0 for an infinite operand and
|
||||
# for an infinite or NaN result, so neither can travel through an image: the operand is made in-kernel (+-1 times
|
||||
# exp(100), which overflows to +-inf) and the result is read back through three comparisons with 0.
|
||||
INFINITE = [
|
||||
("cycles_power", "POWER", [
|
||||
(-1.0, INF), (-1.0, -INF), (-2.0, INF), (-2.0, -INF), (-0.5, INF), (-0.5, -INF), (-0.0, INF), (-0.0, -INF),
|
||||
(0.5, INF), (0.5, -INF), (2.0, INF), (2.0, -INF), (1.0, INF), (0.0, INF), (0.0, -INF), (INF, 3.0), (-INF, 3.0),
|
||||
(-INF, 2.0), (INF, -1.0), (-INF, -1.0), (-INF, INF), (INF, INF)]),
|
||||
("cycles_modulo", "MODULO", [
|
||||
(INF, 1.0), (-INF, 1.0), (INF, 3.0), (-INF, 3.0), (INF, 0.1), (-INF, 0.1), (INF, -2.0), (5.0, INF), (-5.0, INF),
|
||||
(5.0, -INF), (INF, INF), (-INF, INF), (0.0, INF), (INF, 0.0)]),
|
||||
]
|
||||
|
||||
# Map Range, Smooth Step: (value, from_min, from_max, to_min, to_max).
|
||||
INFINITE_SMOOTHSTEP = [
|
||||
(-INF, -INF, 1.0, 2.0, 3.0), (-INF, 1.0, -INF, 2.0, 3.0), (-INF, 1.0, 5.0, 2.0, 3.0), (-INF, -5.0, 1.0, 2.0, 3.0),
|
||||
(INF, -5.0, 1.0, 2.0, 3.0), (INF, 1.0, -5.0, 2.0, 3.0), (INF, INF, 1.0, 2.0, 3.0), (INF, 1.0, INF, 2.0, 3.0),
|
||||
(-INF, INF, 1.0, 2.0, 3.0), (-INF, 1.0, INF, 2.0, 3.0), (1.0, -INF, INF, 2.0, 3.0),
|
||||
]
|
||||
|
||||
|
||||
def result_token(value, greater, less, equal):
|
||||
"""A result as the fixture writes it: float32 bits, or inf / -inf / nan where the film showed 0 and the comparisons say why."""
|
||||
if bits(value) != "00000000" and bits(value) != "80000000":
|
||||
return bits(value)
|
||||
if equal:
|
||||
return bits(value)
|
||||
return "inf" if greater else ("-inf" if less else "nan")
|
||||
|
||||
|
||||
def infinite_entry(function, node_label, build_node, rows):
|
||||
count = len(rows[0])
|
||||
value_columns = []
|
||||
flag_columns = []
|
||||
for k in range(count):
|
||||
value_columns.append(f32([(0.0 if r >= len(rows) else (float(np.sign(rows[r][k])) if np.isinf(rows[r][k]) else rows[r][k])) for r in range(CASES)]))
|
||||
flag_columns.append(f32([(0.0 if r >= len(rows) else (1.0 if np.isinf(rows[r][k]) else 0.0)) for r in range(CASES)]))
|
||||
images = pack_operands(value_columns + flag_columns)
|
||||
|
||||
def build(tree, imgs):
|
||||
sockets = operand_sockets(tree, imgs, 2 * count)
|
||||
operands = []
|
||||
for k in range(count):
|
||||
hundred = tree.nodes.new("ShaderNodeMath")
|
||||
hundred.operation = "MULTIPLY"
|
||||
tree.links.new(sockets[count + k], hundred.inputs[0])
|
||||
hundred.inputs[1].default_value = 100.0
|
||||
overflow = tree.nodes.new("ShaderNodeMath")
|
||||
overflow.operation = "EXPONENT"
|
||||
tree.links.new(hundred.outputs[0], overflow.inputs[0])
|
||||
made = tree.nodes.new("ShaderNodeMath")
|
||||
made.operation = "MULTIPLY"
|
||||
tree.links.new(sockets[k], made.inputs[0])
|
||||
tree.links.new(overflow.outputs[0], made.inputs[1])
|
||||
operands.append(made.outputs[0])
|
||||
result = build_node(tree, operands)
|
||||
probes = []
|
||||
for operation in ("GREATER_THAN", "LESS_THAN", "COMPARE"):
|
||||
probe = tree.nodes.new("ShaderNodeMath")
|
||||
probe.operation = operation
|
||||
tree.links.new(result, probe.inputs[0])
|
||||
probe.inputs[1].default_value = 0.0
|
||||
if operation == "COMPARE":
|
||||
probe.inputs[2].default_value = 0.0
|
||||
probes.append(probe.outputs[0])
|
||||
return [result] + probes
|
||||
|
||||
value, greater, less, equal = bake(function + ".infinite", images, build, 4)
|
||||
cases = []
|
||||
for r, row in enumerate(rows):
|
||||
operands = " ".join(("inf" if x == INF else "-inf" if x == -INF else bits(x)) for x in row)
|
||||
cases.append("%s -> %s" % (operands, result_token(value[r], greater[r] > 0.5, less[r] > 0.5, equal[r] > 0.5)))
|
||||
return {"function": function, "node": node_label, "cases": cases}
|
||||
|
||||
|
||||
def family_infinite():
|
||||
def math_node(operation):
|
||||
def build(tree, operands):
|
||||
node = tree.nodes.new("ShaderNodeMath")
|
||||
node.operation = operation
|
||||
for k, socket in enumerate(operands):
|
||||
tree.links.new(socket, node.inputs[k])
|
||||
return node.outputs[0]
|
||||
return build
|
||||
|
||||
def smoothstep(tree, operands):
|
||||
node = tree.nodes.new("ShaderNodeMapRange")
|
||||
node.data_type = "FLOAT"
|
||||
node.interpolation_type = "SMOOTHSTEP"
|
||||
node.clamp = False
|
||||
for identifier, socket in zip(["Value", "From Min", "From Max", "To Min", "To Max"], operands):
|
||||
tree.links.new(socket, by_identifier(node.inputs, identifier))
|
||||
return node.outputs["Result"]
|
||||
|
||||
functions = [infinite_entry(function, "ShaderNodeMath " + operation, math_node(operation), rows) for function, operation, rows in INFINITE]
|
||||
functions.append(infinite_entry("cycles_map_range_smoothstep", "ShaderNodeMapRange SMOOTHSTEP", smoothstep, INFINITE_SMOOTHSTEP))
|
||||
return functions
|
||||
|
||||
|
||||
FAMILIES = {
|
||||
"math": family_math,
|
||||
"map-range": family_map_range,
|
||||
"clamp": family_clamp,
|
||||
"mix": family_mix,
|
||||
"rgb-to-bw": family_rgb_to_bw,
|
||||
"ramp": family_ramp,
|
||||
"infinite": family_infinite,
|
||||
}
|
||||
|
||||
|
||||
def identity_check():
|
||||
"""The bake must return an operand image bit for bit, or no fixture means anything."""
|
||||
rng = np.random.default_rng(1)
|
||||
column = f32(rng.uniform(-200, 200, CASES))
|
||||
images = pack_operands([column])
|
||||
|
||||
def build(tree, imgs):
|
||||
return [operand_sockets(tree, imgs, 1)[0]]
|
||||
|
||||
got = bake("identity", images, build, 1)[0]
|
||||
if not np.array_equal(got.view(np.uint32), column.view(np.uint32)):
|
||||
sys.stderr.write("cycles-oracle: the bake does not return its operands bit for bit\n")
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
def kernel_check():
|
||||
"""The fixtures witness the ruled reference: Cycles' formulas with one IEEE single rounding per operation.
|
||||
|
||||
Ruling cultmath-tapes:ruling:cycles-kernel-reference-ruled. Cycles' AVX2 CPU kernel is compiled with FMA, so
|
||||
a - floor(a / b) b is one fused operation there and 10000 mod 0.1 is -1.5e-4; the SSE4.2 kernel has none, and
|
||||
gives the unfused result 0 that CultMath's C# computes. Other kernels are other builds, measured under the
|
||||
tolerance classes and never written here: run with CYCLES_CPU_NO_AVX2=1.
|
||||
"""
|
||||
a = f32([10000.0] * CASES)
|
||||
b = f32([0.1] * CASES)
|
||||
unfused = a - np.floor(a / b) * b
|
||||
images = pack_operands([a, b])
|
||||
|
||||
def build(tree, imgs):
|
||||
node = tree.nodes.new("ShaderNodeMath")
|
||||
node.operation = "FLOORED_MODULO"
|
||||
sockets = operand_sockets(tree, imgs, 2)
|
||||
tree.links.new(sockets[0], node.inputs[0])
|
||||
tree.links.new(sockets[1], node.inputs[1])
|
||||
return [node.outputs[0]]
|
||||
|
||||
got = bake("kernel", images, build, 1)[0]
|
||||
if not np.array_equal(got.view(np.uint32), unfused.astype(np.float32).view(np.uint32)):
|
||||
sys.stderr.write("cycles-oracle: this Cycles CPU kernel fuses multiply-adds, which is not the reference of ruling cycles-kernel-reference-ruled; run with CYCLES_CPU_NO_AVX2=1\n")
|
||||
sys.exit(4)
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
out = argv[0]
|
||||
wanted = argv[1:] or list(FAMILIES)
|
||||
os.makedirs(out, exist_ok=True)
|
||||
identity_check()
|
||||
kernel_check()
|
||||
header = {
|
||||
"oracle": "tools/cycles-oracle/oracle.py (Blender %s, build %s, Cycles CPU, 1 sample, Emit bake, float Non-Color images)" % (PINNED_VERSION, PINNED_HASH),
|
||||
"blender": PINNED_VERSION,
|
||||
"build_hash": PINNED_HASH,
|
||||
"platform": bpy.app.build_platform.decode() if isinstance(bpy.app.build_platform, bytes) else str(bpy.app.build_platform),
|
||||
"encoding": "each case is 'operands -> results', every scalar a float32 bit pattern in hex, colours flattened as r g b",
|
||||
}
|
||||
for family in wanted:
|
||||
document = dict(header)
|
||||
document["family"] = family
|
||||
document["functions"] = FAMILIES[family]()
|
||||
text = json.dumps(document, indent=1) + "\n"
|
||||
with open(os.path.join(out, family + ".json"), "w", newline="\n") as handle:
|
||||
handle.write(text)
|
||||
print("cycles-oracle: wrote", family, len(document["functions"]), "functions")
|
||||
|
||||
|
||||
main()
|
||||
|
|
@ -7,9 +7,16 @@ files, which are licensed under the Mozilla Public License 2.0 (`MPL-2.0`):
|
|||
- the Phacelle sources compiled into `Runtime/Plugins/CultMath.dll`
|
||||
(`math.Phacelle.cs` and `CultPhasor.cs` in the GameCult/CultLib repository)
|
||||
|
||||
The package license expression is therefore `MIT AND MPL-2.0`, matching the
|
||||
`GameCult.Math` NuGet package. MPL-2.0 is file-level: it applies to those files
|
||||
and not to the rest of CultMath.
|
||||
and for the Cycles node semantics files, which are licensed under the Apache
|
||||
License 2.0 (`Apache-2.0`):
|
||||
|
||||
- `Shaders/CultMath.Cycles.hlsl`
|
||||
- the Cycles sources compiled into `Runtime/Plugins/CultMath.dll`
|
||||
(`math.Cycles.cs` in the GameCult/CultLib repository)
|
||||
|
||||
The package license expression is therefore `MIT AND MPL-2.0 AND Apache-2.0`,
|
||||
matching the `GameCult.Math` NuGet package. MPL-2.0 and Apache-2.0 are file-level:
|
||||
each applies to its files and not to the rest of CultMath.
|
||||
|
||||
The MPL-2.0 text is available from the Mozilla Foundation at
|
||||
<https://www.mozilla.org/MPL/2.0/>. Source and modification history for the
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@
|
|||
|
||||
// An upper bound on the spectral norm of the Hessian of cultmath_snoise(float3); provenance in
|
||||
// math.Affine.cs (SNOISE_HESSIAN).
|
||||
static const float CULTMATH_SNOISE_HESSIAN = 56.050385;
|
||||
static const float CULTMATH_SNOISE_HESSIAN = 55.642143;
|
||||
|
||||
// An upper bound on the operator norm of the third-derivative tensor of cultmath_snoise(float3), the
|
||||
// next order after CULTMATH_SNOISE_HESSIAN; provenance in math.Span.cs (SNOISE_THIRD).
|
||||
static const float CULTMATH_SNOISE_THIRD = 536.9152;
|
||||
|
||||
float3 cultmath_af_point(float value) { return float3(value, 0.0, 0.0); }
|
||||
float3 cultmath_af_symbol(float x0, float a) { return float3(x0, a, 0.0); }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,363 @@
|
|||
#ifndef CULTMATH_CYCLES_HLSL
|
||||
#define CULTMATH_CYCLES_HLSL
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Cycles node semantics: what Blender's Cycles renderer computes for the Math, Map Range, Clamp, Mix and
|
||||
// RGB to BW nodes and for a Color Ramp lookup, ported function by function from the Cycles kernel
|
||||
// (intern/cycles of the Blender v5.2.2 tag; SPDX-FileCopyrightText: 2011-2022 Blender Foundation, Apache-2.0):
|
||||
// kernel/svm/{math_util,map_range,clamp,mix,color_util,ramp,convert}.h, util/math_base.h, util/color.h and
|
||||
// util/math_float3.h. Each function's comment names its Cycles source. Modified by GameCult: Cycles C++
|
||||
// becomes HLSL, a function per node operation, and the node's socket defaults and clamp options are left to
|
||||
// the caller. This file is Apache-2.0 (section 4: it was changed); CultMath.hlsl, which includes it after its
|
||||
// own definitions, is MIT (see THIRD-PARTY-NOTICES.md). math.Cycles.cs mirrors it bit for bit.
|
||||
//
|
||||
// Operands are finite floats. NaN and infinite operands are outside the oracle's reach and the mirror's.
|
||||
|
||||
// FLT_EPSILON, the least epsilon a Compare node honours.
|
||||
static const float CULTMATH_CYCLES_FLT_EPSILON = 1.1920929e-07;
|
||||
|
||||
// The scene-linear luminance coefficients Cycles reads from Blender 5.2.2's default OCIO config
|
||||
// (kernel/film linear_rgb_to_gray). The oracle fixture pins them with a case per primary.
|
||||
static const float CULTMATH_CYCLES_RGB_TO_Y_R = 0.212639093;
|
||||
static const float CULTMATH_CYCLES_RGB_TO_Y_G = 0.715169132;
|
||||
static const float CULTMATH_CYCLES_RGB_TO_Y_B = 0.0721927434;
|
||||
|
||||
// ---- Math node (kernel/svm/math_util.h svm_math; util/math_base.h) ----
|
||||
|
||||
// safe_divide
|
||||
float cultmath_cycles_divide(float a, float b) { return b != 0.0 ? a / b : 0.0; }
|
||||
|
||||
// safe_powf with compatible_powf. A negative base takes an integral exponent's parity; a base below zero
|
||||
// with a fractional exponent is 0.
|
||||
float cultmath_cycles_power(float a, float b)
|
||||
{
|
||||
float whole = b >= 0.0 ? floor(b) : ceil(b);
|
||||
if (a < 0.0 && b != whole)
|
||||
return 0.0;
|
||||
if (b == 0.0)
|
||||
return 1.0;
|
||||
if (a == 0.0)
|
||||
return 0.0;
|
||||
if (a < 0.0)
|
||||
{
|
||||
float magnitude = pow(-a, b);
|
||||
// Odd exactly when the remainder is 1: an infinite exponent leaves NaN here and is even, as powf(a, +-inf) is never negative.
|
||||
return b - 2.0 * floor(0.5 * b) == 1.0 ? -magnitude : magnitude;
|
||||
}
|
||||
return pow(a, b);
|
||||
}
|
||||
|
||||
// safe_logf
|
||||
float cultmath_cycles_logarithm(float a, float b)
|
||||
{
|
||||
if (a <= 0.0 || b <= 0.0)
|
||||
return 0.0;
|
||||
float denominator = log(b);
|
||||
return denominator != 0.0 ? log(a) / denominator : 0.0;
|
||||
}
|
||||
|
||||
// safe_sqrtf
|
||||
float cultmath_cycles_sqrt(float a) { return sqrt(max(a, 0.0)); }
|
||||
|
||||
// inversesqrtf
|
||||
float cultmath_cycles_inverse_sqrt(float a) { return a > 0.0 ? 1.0 / sqrt(a) : 0.0; }
|
||||
|
||||
// safe_modulo is fmodf, the remainder with the dividend's sign, exactly. GLSL has no fmod, and a - b trunc(a / b)
|
||||
// is wrong where the quotient rounds up to an integer or the product rounds (3 mod 0.3 is 0.29999995, not 0).
|
||||
// So this subtracts the largest power-of-two multiple of the divisor not above the remainder until the
|
||||
// remainder is below the divisor: every step is an exact subtraction, as in fmod. The loops are bounded by the
|
||||
// binades a float spans.
|
||||
float cultmath_cycles_modulo(float a, float b)
|
||||
{
|
||||
float remainder = abs(a);
|
||||
float divisor = abs(b);
|
||||
for (int steps = 0; steps < 300 && remainder >= divisor && divisor > 0.0; steps++)
|
||||
{
|
||||
float multiple = divisor;
|
||||
for (int doublings = 0; doublings < 300 && multiple * 2.0 <= remainder; doublings++)
|
||||
multiple = multiple * 2.0;
|
||||
remainder = remainder - multiple;
|
||||
}
|
||||
return b != 0.0 ? (a < 0.0 ? -remainder : remainder) : 0.0;
|
||||
}
|
||||
|
||||
// safe_floored_modulo; a build that contracts it differs by a bound stated at cycles_floored_modulo in math.Cycles.cs and the README
|
||||
float cultmath_cycles_floored_modulo(float a, float b) { return b != 0.0 ? a - floor(a / b) * b : 0.0; }
|
||||
|
||||
// wrapf (note the argument order: value, max, min); contraction bound as for floored_modulo
|
||||
float cultmath_cycles_wrap(float value, float max_value, float min_value)
|
||||
{
|
||||
float range = max_value - min_value;
|
||||
return range != 0.0 ? value - (range * floor((value - min_value) / range)) : min_value;
|
||||
}
|
||||
|
||||
// floorf(safe_divide(a, b)) * b
|
||||
float cultmath_cycles_snap(float a, float b) { return floor(cultmath_cycles_divide(a, b)) * b; }
|
||||
|
||||
// pingpongf
|
||||
float cultmath_cycles_pingpong(float a, float b)
|
||||
{
|
||||
float scaled = (a - b) / (b * 2.0);
|
||||
return b != 0.0 ? abs((scaled - floor(scaled)) * b * 2.0 - b) : 0.0;
|
||||
}
|
||||
|
||||
// smoothminf
|
||||
float cultmath_cycles_smooth_min(float a, float b, float k)
|
||||
{
|
||||
float h = max(k - abs(a - b), 0.0) / k;
|
||||
return k != 0.0 ? min(a, b) - h * h * h * k * 0.16666667 : min(a, b);
|
||||
}
|
||||
|
||||
// -smoothminf(-a, -b, k)
|
||||
float cultmath_cycles_smooth_max(float a, float b, float k) { return -cultmath_cycles_smooth_min(-a, -b, k); }
|
||||
|
||||
// NODE_MATH_COMPARE
|
||||
float cultmath_cycles_compare(float a, float b, float epsilon)
|
||||
{
|
||||
return (a == b || abs(a - b) <= max(epsilon, CULTMATH_CYCLES_FLT_EPSILON)) ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
// NODE_MATH_ROUND
|
||||
float cultmath_cycles_round(float a) { return floor(a + 0.5); }
|
||||
|
||||
// NODE_MATH_TRUNC
|
||||
float cultmath_cycles_trunc(float a) { return a >= 0.0 ? floor(a) : ceil(a); }
|
||||
|
||||
// NODE_MATH_FRACTION
|
||||
float cultmath_cycles_fract(float a) { return a - floor(a); }
|
||||
|
||||
// compatible_signf
|
||||
float cultmath_cycles_sign(float a) { return a == 0.0 ? 0.0 : (a < 0.0 ? -1.0 : 1.0); }
|
||||
|
||||
// safe_asinf
|
||||
float cultmath_cycles_arcsine(float a) { return asin(clamp(a, -1.0, 1.0)); }
|
||||
|
||||
// safe_acosf
|
||||
float cultmath_cycles_arccosine(float a) { return acos(clamp(a, -1.0, 1.0)); }
|
||||
|
||||
// compatible_atan2 (a is y, b is x)
|
||||
float cultmath_cycles_arctan2(float a, float b) { return (b == 0.0 && a == 0.0) ? 0.0 : atan2(a, b); }
|
||||
|
||||
// ---- Map Range node (kernel/svm/map_range.h svm_node_map_range) ----
|
||||
// A Map Range node's Clamp option is a Clamp node of type Range on its result (MapRangeNode::expand):
|
||||
// compose cultmath_cycles_clamp_range over these.
|
||||
|
||||
float cultmath_cycles_map_range_linear(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float factor = (value - from_min) / (from_max - from_min);
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
float cultmath_cycles_map_range_stepped(float value, float from_min, float from_max, float to_min, float to_max, float steps)
|
||||
{
|
||||
float factor = (value - from_min) / (from_max - from_min);
|
||||
factor = steps > 0.0 ? floor(factor * (steps + 1.0)) / steps : 0.0;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// The value itself, not a normalized one, goes through smoothstep(from_min, from_max, .), flipped when
|
||||
// from_min > from_max (util/math_base.h smoothstep).
|
||||
float cultmath_cycles_map_range_smoothstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float low = min(from_min, from_max);
|
||||
float high = max(from_min, from_max);
|
||||
float t = (value - low) / (high - low);
|
||||
float eased = value < low ? 0.0 : (value >= high ? 1.0 : (3.0 - 2.0 * t) * (t * t));
|
||||
float factor = from_min > from_max ? 1.0 - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// smootherstep of map_range.h, flipped when from_min > from_max.
|
||||
float cultmath_cycles_map_range_smootherstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float low = min(from_min, from_max);
|
||||
float high = max(from_min, from_max);
|
||||
float x = clamp(cultmath_cycles_divide(value - low, high - low), 0.0, 1.0);
|
||||
float eased = x * x * x * (x * (x * 6.0 - 15.0) + 10.0);
|
||||
float factor = from_min > from_max ? 1.0 - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// ---- Clamp node (kernel/svm/clamp.h svm_node_clamp) ----
|
||||
|
||||
// Type Min Max
|
||||
float cultmath_cycles_clamp_minmax(float value, float min_value, float max_value) { return clamp(value, min_value, max_value); }
|
||||
|
||||
// Type Range: the bounds swap when min_value > max_value.
|
||||
float cultmath_cycles_clamp_range(float value, float min_value, float max_value)
|
||||
{
|
||||
return min_value > max_value ? clamp(value, max_value, min_value) : clamp(value, min_value, max_value);
|
||||
}
|
||||
|
||||
// ---- Mix node (kernel/svm/mix.h, color_util.h) ----
|
||||
// Factor clamping (svm_node_mix_color's saturatef) and result clamping (saturate) are the caller's.
|
||||
|
||||
// svm_node_mix_float
|
||||
float cultmath_cycles_mix_float(float t, float a, float b) { return a * (1.0 - t) + b * t; }
|
||||
|
||||
// svm_node_mix_vector
|
||||
float3 cultmath_cycles_mix_vector(float t, float3 a, float3 b) { return a * (float3(1.0, 1.0, 1.0) - t) + b * t; }
|
||||
|
||||
float3 cultmath_cycles_mix_blend(float t, float3 col1, float3 col2) { return col1 + t * (col2 - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_add(float t, float3 col1, float3 col2) { return col1 + t * ((col1 + col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_mul(float t, float3 col1, float3 col2) { return col1 + t * (col1 * col2 - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_screen(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 one = float3(1.0, 1.0, 1.0);
|
||||
return one - (float3(tm, tm, tm) + t * (one - col2)) * (one - col1);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_overlay(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
return float3(
|
||||
col1.x < 0.5 ? col1.x * (tm + 2.0 * t * col2.x) : 1.0 - (tm + 2.0 * t * (1.0 - col2.x)) * (1.0 - col1.x),
|
||||
col1.y < 0.5 ? col1.y * (tm + 2.0 * t * col2.y) : 1.0 - (tm + 2.0 * t * (1.0 - col2.y)) * (1.0 - col1.y),
|
||||
col1.z < 0.5 ? col1.z * (tm + 2.0 * t * col2.z) : 1.0 - (tm + 2.0 * t * (1.0 - col2.z)) * (1.0 - col1.z));
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_sub(float t, float3 col1, float3 col2) { return col1 + t * ((col1 - col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_div(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
return float3(
|
||||
col2.x != 0.0 ? tm * col1.x + t * col1.x / col2.x : col1.x,
|
||||
col2.y != 0.0 ? tm * col1.y + t * col1.y / col2.y : col1.y,
|
||||
col2.z != 0.0 ? tm * col1.z + t * col1.z / col2.z : col1.z);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_diff(float t, float3 col1, float3 col2) { return col1 + t * (abs(col1 - col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_exclusion(float t, float3 col1, float3 col2)
|
||||
{
|
||||
return max(col1 + t * ((col1 + col2 - 2.0 * col1 * col2) - col1), float3(0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_dark(float t, float3 col1, float3 col2) { return col1 + t * (min(col1, col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_light(float t, float3 col1, float3 col2) { return col1 + t * (max(col1, col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_dodge(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float3 divisor = float3(1.0, 1.0, 1.0) - t * col2;
|
||||
return float3(
|
||||
col1.x != 0.0 ? (divisor.x <= 0.0 ? 1.0 : min(col1.x / divisor.x, 1.0)) : col1.x,
|
||||
col1.y != 0.0 ? (divisor.y <= 0.0 ? 1.0 : min(col1.y / divisor.y, 1.0)) : col1.y,
|
||||
col1.z != 0.0 ? (divisor.z <= 0.0 ? 1.0 : min(col1.z / divisor.z, 1.0)) : col1.z);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_burn(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 divisor = tm + t * col2;
|
||||
float3 burnt = float3(1.0, 1.0, 1.0) - (float3(1.0, 1.0, 1.0) - col1) / divisor;
|
||||
return float3(
|
||||
divisor.x <= 0.0 ? 0.0 : clamp(burnt.x, 0.0, 1.0),
|
||||
divisor.y <= 0.0 ? 0.0 : clamp(burnt.y, 0.0, 1.0),
|
||||
divisor.z <= 0.0 ? 0.0 : clamp(burnt.z, 0.0, 1.0));
|
||||
}
|
||||
|
||||
// rgb_to_hsv of util/color.h
|
||||
float3 cultmath_cycles_rgb_to_hsv(float3 rgb)
|
||||
{
|
||||
float cmax = max(rgb.x, max(rgb.y, rgb.z));
|
||||
float cmin = min(rgb.x, min(rgb.y, rgb.z));
|
||||
float cdelta = cmax - cmin;
|
||||
float s = cmax != 0.0 ? cdelta / cmax : 0.0;
|
||||
float3 c = (float3(cmax, cmax, cmax) - rgb) / cdelta;
|
||||
float h = rgb.x == cmax ? c.z - c.y : (rgb.y == cmax ? 2.0 + c.x - c.z : 4.0 + c.y - c.x);
|
||||
h = h * 0.16666667; // Cycles' kernel is built with reciprocal math: h /= 6.0f computes h * (1 / 6.0f).
|
||||
h = h < 0.0 ? h + 1.0 : h;
|
||||
return float3(s != 0.0 ? h : 0.0, s, cmax);
|
||||
}
|
||||
|
||||
// hsv_to_rgb of util/color.h
|
||||
float3 cultmath_cycles_hsv_to_rgb(float3 hsv)
|
||||
{
|
||||
float h = hsv.x == 1.0 ? 0.0 : hsv.x;
|
||||
h = h * 6.0;
|
||||
float i = floor(h);
|
||||
float f = h - i;
|
||||
float p = hsv.z * (1.0 - hsv.y);
|
||||
float q = hsv.z * (1.0 - (hsv.y * f));
|
||||
float t = hsv.z * (1.0 - (hsv.y * (1.0 - f)));
|
||||
float3 rgb = i == 0.0 ? float3(hsv.z, t, p) :
|
||||
(i == 1.0 ? float3(q, hsv.z, p) :
|
||||
(i == 2.0 ? float3(p, hsv.z, t) :
|
||||
(i == 3.0 ? float3(p, q, hsv.z) :
|
||||
(i == 4.0 ? float3(t, p, hsv.z) : float3(hsv.z, p, q)))));
|
||||
return hsv.y != 0.0 ? rgb : float3(hsv.z, hsv.z, hsv.z);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_hue(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 shifted = cultmath_cycles_hsv_to_rgb(float3(hsv2.x, hsv.y, hsv.z));
|
||||
return hsv2.y != 0.0 ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_sat(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
return hsv.y != 0.0 ? cultmath_cycles_hsv_to_rgb(float3(hsv.x, tm * hsv.y + t * hsv2.y, hsv.z)) : col1;
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_val(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
return cultmath_cycles_hsv_to_rgb(float3(hsv.x, hsv.y, tm * hsv.z + t * hsv2.z));
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_color(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 shifted = cultmath_cycles_hsv_to_rgb(float3(hsv2.x, hsv2.y, hsv.z));
|
||||
return hsv2.y != 0.0 ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_soft(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 one = float3(1.0, 1.0, 1.0);
|
||||
float3 scr = one - (one - col2) * (one - col1);
|
||||
return tm * col1 + t * ((one - col1) * col2 * col1 + col1 * scr);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_linear(float t, float3 col1, float3 col2)
|
||||
{
|
||||
return col1 + t * (2.0 * col2 + float3(-1.0, -1.0, -1.0));
|
||||
}
|
||||
|
||||
// ---- RGB to BW node (kernel/svm/convert.h NODE_CONVERT_CF) ----
|
||||
|
||||
float cultmath_cycles_rgb_to_bw(float3 color)
|
||||
{
|
||||
return color.x * CULTMATH_CYCLES_RGB_TO_Y_R + color.y * CULTMATH_CYCLES_RGB_TO_Y_G + color.z * CULTMATH_CYCLES_RGB_TO_Y_B;
|
||||
}
|
||||
|
||||
// ---- Color Ramp lookup (kernel/svm/ramp.h rgb_ramp_lookup, without extrapolation) ----
|
||||
|
||||
// Where a ramp of `size` table entries is read at t: (index, weight), the lower entry and the weight of the
|
||||
// next one. The colour is table[index] when weight is 0 and (1 - weight) table[index] + weight table[index + 1]
|
||||
// otherwise. The ramp table itself is the caller's; Blender's holds 257 entries, the ramp sampled at i / 256.
|
||||
float2 cultmath_cycles_ramp_index(float t, int size)
|
||||
{
|
||||
float f = (t > 0.0 ? (t < 1.0 ? t : 1.0) : 0.0) * (float)(size - 1);
|
||||
int index = clamp((int)f, 0, size - 1);
|
||||
float lower = (float)index;
|
||||
return float2(lower, f - lower);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 477bb38d537244b78e3eaec093530bcc
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -228,6 +228,97 @@ float4 cultmath_snoise_grad(float3 value)
|
|||
return float4(grad, value2);
|
||||
}
|
||||
|
||||
// Analytic Hessian of cultmath_snoise(float3); see the C# math.snoise_hessian comment for the
|
||||
// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise_grad's own. The matrix is
|
||||
// built from nine scalars, which read as rows in HLSL and as columns in GLSL; it is symmetric, so the
|
||||
// two agree.
|
||||
float3x3 cultmath_snoise_hessian(float3 value)
|
||||
{
|
||||
float2 c = float2(1.0 / 6.0, 1.0 / 3.0);
|
||||
float3 i = floor(value + dot(value, c.yyy));
|
||||
float3 x0 = value - i + dot(i, c.xxx);
|
||||
float3 g = float3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x));
|
||||
float3 l = 1.0 - g;
|
||||
float3 i1 = min(g.xyz, l.zxy);
|
||||
float3 i2 = max(g.xyz, l.zxy);
|
||||
float3 x1 = x0 - i1 + c.xxx;
|
||||
float3 x2 = x0 - i2 + c.yyy;
|
||||
float3 x3 = x0 - 0.5;
|
||||
|
||||
i = cultmath_snoise_mod289(i);
|
||||
float4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute(
|
||||
i.z + float4(0.0, i1.z, i2.z, 1.0)) + i.y + float4(0.0, i1.y, i2.y, 1.0)) + i.x + float4(0.0, i1.x, i2.x, 1.0));
|
||||
|
||||
const float n_ = 0.142857142857;
|
||||
float3 ns = float3(2.0 * n_, 0.5 * n_ - 1.0, n_);
|
||||
float4 j = p - 49.0 * floor(p * ns.z * ns.z);
|
||||
float4 x_ = floor(j * ns.z);
|
||||
float4 y_ = floor(j - 7.0 * x_);
|
||||
float4 x = x_ * ns.x + ns.yyyy;
|
||||
float4 y = y_ * ns.x + ns.yyyy;
|
||||
float4 h = 1.0 - abs(x) - abs(y);
|
||||
|
||||
float4 b0 = float4(x.xy, y.xy);
|
||||
float4 b1 = float4(x.zw, y.zw);
|
||||
float4 s0 = floor(b0) * 2.0 + 1.0;
|
||||
float4 s1 = floor(b1) * 2.0 + 1.0;
|
||||
float4 sh = -step(h, float4(0.0, 0.0, 0.0, 0.0));
|
||||
float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
|
||||
float4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
|
||||
|
||||
float3 p0 = float3(a0.xy, h.x);
|
||||
float3 p1 = float3(a0.zw, h.y);
|
||||
float3 p2 = float3(a1.xy, h.z);
|
||||
float3 p3 = float3(a1.zw, h.w);
|
||||
float4 norm = 1.79284291400159 - 0.85373472095314 * float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3));
|
||||
p0 *= norm.x;
|
||||
p1 *= norm.y;
|
||||
p2 *= norm.z;
|
||||
p3 *= norm.w;
|
||||
|
||||
float4 m0 = max(0.5 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
|
||||
float4 m2 = m0 * m0;
|
||||
float4 m3 = m2 * m0;
|
||||
float4 px = float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3));
|
||||
float4 wide = 48.0 * m2 * px;
|
||||
float4 narrow = 8.0 * m3;
|
||||
|
||||
float4 cxx = float4(x0.x, x1.x, x2.x, x3.x);
|
||||
float4 cxy = float4(x0.y, x1.y, x2.y, x3.y);
|
||||
float4 cxz = float4(x0.z, x1.z, x2.z, x3.z);
|
||||
float4 cpx = float4(p0.x, p1.x, p2.x, p3.x);
|
||||
float4 cpy = float4(p0.y, p1.y, p2.y, p3.y);
|
||||
float4 cpz = float4(p0.z, p1.z, p2.z, p3.z);
|
||||
|
||||
float hxx = 105.0 * (dot(wide * cxx, cxx) - dot(narrow, 2.0 * cpx * cxx + px));
|
||||
float hyy = 105.0 * (dot(wide * cxy, cxy) - dot(narrow, 2.0 * cpy * cxy + px));
|
||||
float hzz = 105.0 * (dot(wide * cxz, cxz) - dot(narrow, 2.0 * cpz * cxz + px));
|
||||
float hxy = 105.0 * (dot(wide * cxx, cxy) - dot(narrow, cpx * cxy + cpy * cxx));
|
||||
float hxz = 105.0 * (dot(wide * cxx, cxz) - dot(narrow, cpx * cxz + cpz * cxx));
|
||||
float hyz = 105.0 * (dot(wide * cxy, cxz) - dot(narrow, cpy * cxz + cpz * cxy));
|
||||
return float3x3(hxx, hxy, hxz, hxy, hyy, hyz, hxz, hyz, hzz);
|
||||
}
|
||||
|
||||
// The integral over [t0, t1] of clamp(a + b t, lo, hi); see the C# math.integral_clamp_linear comment.
|
||||
float cultmath_integral_clamp_linear(float a, float b, float lo, float hi, float t0, float t1)
|
||||
{
|
||||
if (b == 0.0)
|
||||
return clamp(a, lo, hi) * (t1 - t0);
|
||||
|
||||
lo = min(lo, hi);
|
||||
float direction = t1 < t0 ? -1.0 : 1.0;
|
||||
float start = min(t0, t1);
|
||||
float stop = max(t0, t1);
|
||||
float atLo = (lo - a) / b;
|
||||
float atHi = (hi - a) / b;
|
||||
float enter = clamp(min(atLo, atHi), start, stop);
|
||||
float leave = clamp(max(atLo, atHi), start, stop);
|
||||
float before = b > 0.0 ? lo : hi;
|
||||
float after = b > 0.0 ? hi : lo;
|
||||
float total = before * (enter - start) + (a + b * (0.5 * (enter + leave))) * (leave - enter) + after * (stop - leave);
|
||||
return direction * total;
|
||||
}
|
||||
|
||||
// Octave sum of cultmath_snoise_grad (invariant 8); see the C# math.fbm_grad comment for the
|
||||
// per-octave frequency/amplitude derivation and why octaves is clamped.
|
||||
float4 cultmath_fbm_grad(float3 p, int octaves, float lacunarity, float gain)
|
||||
|
|
@ -509,6 +600,7 @@ CultCellular cultmath_cellular(float3 p)
|
|||
|
||||
#include "CultMath.Phacelle.hlsl"
|
||||
#include "CultMath.Interval.hlsl"
|
||||
#include "CultMath.Cycles.hlsl"
|
||||
#include "CultMath.Affine.hlsl"
|
||||
|
||||
float cultmath_value_noise(float2 position)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,35 @@ permutation and scale as `snoise(float3)`.
|
|||
- Upstream: <https://github.com/ashima/webgl-noise>
|
||||
- License: MIT.
|
||||
|
||||
## Cycles node semantics
|
||||
|
||||
`math.cycles_*` and `cultmath_cycles_*` port what Blender's Cycles renderer
|
||||
computes for the Math, Map Range, Clamp, Mix and RGB to BW nodes and for a Color
|
||||
Ramp lookup. Each function's comment names the Cycles file it follows, all from
|
||||
`intern/cycles` at the Blender `v5.2.2` tag: `kernel/svm/math_util.h`,
|
||||
`kernel/svm/map_range.h`, `kernel/svm/clamp.h`, `kernel/svm/mix.h`,
|
||||
`kernel/svm/color_util.h`, `kernel/svm/ramp.h`, `kernel/svm/convert.h`,
|
||||
`util/math_base.h`, `util/math_float3.h` and `util/color.h`. The code was
|
||||
changed: Cycles C++ became C# and HLSL, one function per node operation, with
|
||||
the nodes' socket defaults and clamp options left to the caller, and the modulo
|
||||
is the quotient form every CultMath target shares rather than `fmodf`. Nothing
|
||||
was read from Blender's GPL sources (`source/blender/gpu`, `source/blender/blenlib`).
|
||||
|
||||
- Copyright 2011-2022 Blender Foundation.
|
||||
- Upstream: <https://projects.blender.org/blender/blender> (`intern/cycles`).
|
||||
- License: Apache-2.0 (<https://www.apache.org/licenses/LICENSE-2.0>), which is
|
||||
per-file here: it covers `src/CultMath/math.Cycles.cs`,
|
||||
`shaders/CultMath.Cycles.hlsl` and the Unity copy of that include, and
|
||||
`shaders/CultMath.Cycles.glsl`, which is generated from that include. Each
|
||||
carries the Apache-2.0 header and this attribution. The rest of CultMath, the
|
||||
generated GLSL library included, is MIT or MPL-2.0 as above, so the
|
||||
`GameCult.Math` NuGet package and the `org.gamecult.cultmath` Unity package
|
||||
both declare `MIT AND MPL-2.0 AND Apache-2.0` and ship this file.
|
||||
- The test fixtures under `tests/CultMath.Tests/fixtures/cycles-oracle` are
|
||||
results of running Blender 5.2.2 through `tools/cycles-oracle/oracle.py`
|
||||
(GPL-3.0-or-later, a Blender Python script, shipped neither in the NuGet
|
||||
package nor in the Unity package).
|
||||
|
||||
## Phacelle Noise
|
||||
|
||||
`math.phacelle` and `cultmath_phacelle` follow Rune Skovbo Johansen's Phacelle
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "0.4.1",
|
||||
"unity": "2021.3",
|
||||
"description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.",
|
||||
"license": "MIT AND MPL-2.0",
|
||||
"license": "MIT AND MPL-2.0 AND Apache-2.0",
|
||||
"author": {
|
||||
"name": "GameCult"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ public static class CultDocumentMessagePackSerialization
|
|||
// The guard goes first and wraps whatever formatter the rest of the composite would hand out, so a consumer
|
||||
// resolver still encodes an arm while the key check runs.
|
||||
var inner = CompositeResolver.Create(
|
||||
consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(StandardResolver.Instance).ToArray());
|
||||
consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(CultMathExprResolver.Instance).Append(StandardResolver.Instance).ToArray());
|
||||
return MessagePackSerializerOptions.Standard
|
||||
.WithResolver(CompositeResolver.Create(new CultUnionGuard(inner), inner))
|
||||
.WithSecurity(CultMessagePackSecurity.Instance);
|
||||
|
|
|
|||
152
src/GameCult.Caching.MessagePack/CultMathExprResolver.cs
Normal file
152
src/GameCult.Caching.MessagePack/CultMathExprResolver.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using CultMath.Expr;
|
||||
using MessagePack;
|
||||
using MessagePack.Formatters;
|
||||
|
||||
namespace GameCult.Caching.MessagePack;
|
||||
|
||||
/// <summary>
|
||||
/// The canonical MessagePack encoding of <see cref="ExprGraph"/>, beside <see cref="CultMathResolver"/> in the
|
||||
/// default chain. A graph is <c>[version, inputs, params, outputs, nodes]</c>: <c>inputs</c> are names; a param is
|
||||
/// <c>[name, default float bits]</c>; an output is <c>[name, node]</c>; a node is <c>[wire name, operands, payload]</c>
|
||||
/// (the payload is the port index of an input or param, the float bits of a constant, the result index of a pick,
|
||||
/// zero for a call). Decoding runs <see cref="ExprGraph.Create"/>, the one validator, so a graph that breaks a rule
|
||||
/// (unknown op, newer version, forward operand) is refused whole with an <see cref="ExprGraphException"/> and never
|
||||
/// partly loaded. Decoding also refuses any encoding other than the one <c>Serialize</c> writes (a wider integer,
|
||||
/// a longer length prefix), so a graph has one byte string. A nil graph is nil, as in every reference slot.
|
||||
/// </summary>
|
||||
public sealed class CultMathExprResolver : IFormatterResolver
|
||||
{
|
||||
public static readonly CultMathExprResolver Instance = new();
|
||||
private CultMathExprResolver() { }
|
||||
|
||||
public IMessagePackFormatter<T>? GetFormatter<T>() => Cache<T>.Formatter;
|
||||
|
||||
private static class Cache<T>
|
||||
{
|
||||
public static readonly IMessagePackFormatter<T>? Formatter =
|
||||
typeof(T) == typeof(ExprGraph) ? (IMessagePackFormatter<T>)(object)new GraphFormatter() : null;
|
||||
}
|
||||
|
||||
private sealed class GraphFormatter : IMessagePackFormatter<ExprGraph?>
|
||||
{
|
||||
public void Serialize(ref MessagePackWriter writer, ExprGraph? value, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
writer.WriteNil();
|
||||
return;
|
||||
}
|
||||
|
||||
Write(ref writer, value);
|
||||
}
|
||||
|
||||
private static void Write(ref MessagePackWriter writer, ExprGraph value)
|
||||
{
|
||||
writer.WriteArrayHeader(5);
|
||||
writer.Write(ExprGraph.FormatVersion);
|
||||
|
||||
writer.WriteArrayHeader(value.InputNames.Count);
|
||||
foreach (var name in value.InputNames) writer.Write(name);
|
||||
|
||||
writer.WriteArrayHeader(value.ParamNames.Count);
|
||||
for (var i = 0; i < value.ParamNames.Count; i++)
|
||||
{
|
||||
writer.WriteArrayHeader(2);
|
||||
writer.Write(value.ParamNames[i]);
|
||||
writer.Write(value.ParamDefaultBits[i]);
|
||||
}
|
||||
|
||||
writer.WriteArrayHeader(value.OutputNames.Count);
|
||||
for (var i = 0; i < value.OutputNames.Count; i++)
|
||||
{
|
||||
writer.WriteArrayHeader(2);
|
||||
writer.Write(value.OutputNames[i]);
|
||||
writer.Write(value.OutputNodes[i]);
|
||||
}
|
||||
|
||||
writer.WriteArrayHeader(value.Nodes.Count);
|
||||
foreach (var node in value.Nodes)
|
||||
{
|
||||
writer.WriteArrayHeader(3);
|
||||
writer.Write(node.WireName);
|
||||
writer.WriteArrayHeader(node.Operands.Count);
|
||||
foreach (var operand in node.Operands) writer.Write(operand);
|
||||
writer.Write(node.Payload);
|
||||
}
|
||||
}
|
||||
|
||||
public ExprGraph? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (reader.TryReadNil()) return null;
|
||||
options.Security.DepthStep(ref reader);
|
||||
try
|
||||
{
|
||||
var start = reader.Position;
|
||||
Expect(ref reader, 5);
|
||||
var version = reader.ReadInt32();
|
||||
|
||||
var inputs = new List<string>();
|
||||
for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++) inputs.Add(ReadName(ref reader));
|
||||
|
||||
var paramNames = new List<string>();
|
||||
var paramBits = new List<int>();
|
||||
for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++)
|
||||
{
|
||||
Expect(ref reader, 2);
|
||||
paramNames.Add(ReadName(ref reader));
|
||||
paramBits.Add(reader.ReadInt32());
|
||||
}
|
||||
|
||||
var outputNames = new List<string>();
|
||||
var outputNodes = new List<int>();
|
||||
for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++)
|
||||
{
|
||||
Expect(ref reader, 2);
|
||||
outputNames.Add(ReadName(ref reader));
|
||||
outputNodes.Add(reader.ReadInt32());
|
||||
}
|
||||
|
||||
var nodes = new List<ExprNode>();
|
||||
for (int i = 0, n = reader.ReadArrayHeader(); i < n; i++)
|
||||
{
|
||||
Expect(ref reader, 3);
|
||||
var wireName = ReadName(ref reader);
|
||||
var operands = new List<int>();
|
||||
for (int k = 0, m = reader.ReadArrayHeader(); k < m; k++) operands.Add(reader.ReadInt32());
|
||||
nodes.Add(ExprNode.FromWire(wireName, operands, reader.ReadInt32()));
|
||||
}
|
||||
|
||||
var graph = ExprGraph.Create(version, inputs, paramNames, paramBits, outputNames, outputNodes, nodes);
|
||||
RequireCanonical(ref reader, start, graph);
|
||||
return graph;
|
||||
}
|
||||
finally
|
||||
{
|
||||
reader.Depth--;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The bytes just read must be the bytes <see cref="Write"/> produces for the graph they decoded to.</summary>
|
||||
private static void RequireCanonical(ref MessagePackReader reader, SequencePosition start, ExprGraph graph)
|
||||
{
|
||||
var canonical = new ArrayBufferWriter<byte>();
|
||||
var writer = new MessagePackWriter(canonical);
|
||||
Write(ref writer, graph);
|
||||
writer.Flush();
|
||||
var read = reader.Sequence.Slice(start, reader.Position);
|
||||
if (read.Length != canonical.WrittenCount || !read.ToArray().AsSpan().SequenceEqual(canonical.WrittenSpan))
|
||||
throw new ExprGraphException("non-canonical");
|
||||
}
|
||||
|
||||
private static void Expect(ref MessagePackReader reader, int count)
|
||||
{
|
||||
if (reader.ReadArrayHeader() != count) throw new ExprGraphException("shape");
|
||||
}
|
||||
|
||||
private static string ReadName(ref MessagePackReader reader) =>
|
||||
reader.ReadString() ?? throw new ExprGraphException("shape");
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ format for the attribute-first `GameCult.Caching` stack.
|
|||
- `CultDocumentResolver`
|
||||
- `CultRecordRefFormatter<T>`
|
||||
- `CultMathResolver`: the canonical component-array encoding of every public CultMath value type, part of the default resolver chain
|
||||
- `CultMathExprResolver`: the canonical encoding of `CultMath.Expr.ExprGraph`, decoded through its validator, part of the default resolver chain
|
||||
|
||||
Cult document payloads serialize through `MessagePackSerializer` with the
|
||||
options of the document's assembly (`CultDocumentMessagePackSerialization.OptionsFor`),
|
||||
|
|
@ -83,6 +84,13 @@ refusal on write is a `JsonException` where System.Text.Json throws
|
|||
`ArgumentException`. A consumer resolver or converter
|
||||
registered ahead of these wins.
|
||||
|
||||
`CultMath.Expr.ExprGraph` is a class, not a value type, and has its own shape
|
||||
(`CultMathExprResolver`, in the default chain): `[version, inputs, params, outputs, nodes]`,
|
||||
with a param `[name, default float bits]`, an output `[name, node]` and a node
|
||||
`[op name, operands, payload]`. Decoding runs the graph's one validator and refuses a
|
||||
broken graph whole (an `ExprGraphException` naming the rule), so a consumer
|
||||
`[CultDocument]` record with an `ExprGraph` field needs no registration. A nil graph is nil, as in every reference slot; decoding refuses any encoding but the canonical one.
|
||||
|
||||
| Type | MessagePack | JSON |
|
||||
| --- | --- | --- |
|
||||
| `float2`, `double2`, `int2`, `bool2` | `[x, y]` | `{"x":..,"y":..}` |
|
||||
|
|
|
|||
266
tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs
Normal file
266
tests/GameCult.Caching.Tests/CultMathExprSerializationTests.cs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CultMath.Expr;
|
||||
using FluentAssertions;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Caching.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// The MessagePack shape of <c>CultMath.Expr.ExprGraph</c> (<c>CultMathExprResolver</c>, in the default chain): a
|
||||
/// consumer record with a graph field needs no registration, a graph encodes to canonical bytes and decodes to a
|
||||
/// graph that evaluates bit for bit the same, and decode runs the one validator so a broken encoding is refused
|
||||
/// whole, naming its rule and never echoing what it was given.
|
||||
/// </summary>
|
||||
public sealed class CultMathExprSerializationTests
|
||||
{
|
||||
private static readonly MessagePackSerializerOptions Options = CultDocumentMessagePackSerialization.Options;
|
||||
|
||||
private const string Canary = "CANARY_NAME";
|
||||
|
||||
[CultDocument("tests.expr_holder", "tests.expr_holder.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
[CultGlobal]
|
||||
internal sealed class ExprHolder
|
||||
{
|
||||
[Key(0)] public ExprGraph Graph = default!;
|
||||
[Key(1)] public float Scale;
|
||||
}
|
||||
|
||||
private static ExprGraph Sample()
|
||||
{
|
||||
var b = new ExprBuilder();
|
||||
var x = b.Input("x");
|
||||
var z = b.Input("z");
|
||||
var k = b.Param("k", 1.5f);
|
||||
var cell = b.Call("cellular3", x, z, k);
|
||||
var field = b.Call("snoise2", b.Call("mul", x, k), z);
|
||||
b.Output("o", b.Call("add", field, b.Call("sub", b.Pick(cell, 7), b.Const(-0f))));
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
private static byte[] Encode(ExprGraph graph) => MessagePackSerializer.Serialize(graph, Options);
|
||||
|
||||
private static ExprGraph Decode(byte[] bytes) => MessagePackSerializer.Deserialize<ExprGraph>(bytes, Options);
|
||||
|
||||
private static float[] Evaluate(ExprGraph graph, float[] x, float[] z)
|
||||
{
|
||||
var outputs = new float[x.Length * graph.OutputNames.Count];
|
||||
ExprEvaluator.Evaluate(graph, ExprBindings.Bind(graph), x.Concat(z).ToArray(), outputs);
|
||||
return outputs;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ConsumerRecordRoundTrips()
|
||||
{
|
||||
var graph = Sample();
|
||||
var record = new ExprHolder { Graph = graph, Scale = 2f };
|
||||
|
||||
var bytes = MessagePackSerializer.Serialize(record, Options);
|
||||
var decoded = MessagePackSerializer.Deserialize<ExprHolder>(bytes, Options);
|
||||
|
||||
decoded.Scale.Should().Be(2f);
|
||||
Encode(decoded.Graph).Should().Equal(Encode(graph));
|
||||
var rng = new Random(11);
|
||||
var x = Enumerable.Range(0, 256).Select(_ => (float)(rng.NextDouble() * 20 - 10)).ToArray();
|
||||
var z = Enumerable.Range(0, 256).Select(_ => (float)(rng.NextDouble() * 20 - 10)).ToArray();
|
||||
var expected = Evaluate(graph, x, z);
|
||||
var actual = Evaluate(decoded.Graph, x, z);
|
||||
actual.Select(BitConverter.SingleToInt32Bits).Should().Equal(expected.Select(BitConverter.SingleToInt32Bits));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildIsCanonical_TheSameCallsEncodeToTheSameBytes()
|
||||
{
|
||||
Encode(Sample()).Should().Equal(Encode(Sample()));
|
||||
|
||||
var b = new ExprBuilder();
|
||||
var x = b.Input("x");
|
||||
var k = b.Param("k", 1.5f);
|
||||
b.Output("o", b.Call("add", x, k));
|
||||
MessagePackSerializer.ConvertToJson(Encode(b.Build()), Options).Should().Be(
|
||||
"[1,[\"x\"],[[\"k\",1069547520]],[[\"o\",2]],[[\"input\",[],0],[\"param\",[],0],[\"add\",[0,1],0]]]");
|
||||
|
||||
var dead = new ExprBuilder();
|
||||
var dx = dead.Input("x");
|
||||
var dk = dead.Param("k", 1.5f);
|
||||
dead.Call("sin", dx);
|
||||
dead.Output("o", dead.Call("add", dx, dk));
|
||||
Encode(dead.Build()).Should().Equal(Encode(b.Build()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ConstantsKeepTheirBits()
|
||||
{
|
||||
const int nanBits = 0x7FC12345;
|
||||
var b = new ExprBuilder();
|
||||
b.Output("a", b.Const(-0f));
|
||||
b.Output("b", b.Const(BitConverter.Int32BitsToSingle(nanBits)));
|
||||
b.Output("c", b.Const(BitConverter.Int32BitsToSingle(1)));
|
||||
b.Output("d", b.Param("p", BitConverter.Int32BitsToSingle(nanBits)));
|
||||
var graph = b.Build();
|
||||
|
||||
var decoded = Decode(Encode(graph));
|
||||
|
||||
Encode(decoded).Should().Equal(Encode(graph));
|
||||
decoded.Nodes.Where(n => n.Kind == ExprNodeKind.Const).Select(n => n.Payload)
|
||||
.Should().Equal(int.MinValue, nanBits, 1);
|
||||
decoded.ParamDefaultBits.Should().Equal(nanBits);
|
||||
var outputs = new float[4];
|
||||
ExprEvaluator.Evaluate(decoded, ExprBindings.Bind(decoded), ReadOnlySpan<float>.Empty, outputs);
|
||||
outputs.Select(BitConverter.SingleToInt32Bits).Should().Equal(int.MinValue, nanBits, 1, nanBits);
|
||||
}
|
||||
|
||||
private static object[] Input() => new object[] { "input", new object[0], 0 };
|
||||
|
||||
private static void Put(ref MessagePackWriter writer, object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null: writer.WriteNil(); break;
|
||||
case int i: writer.Write(i); break;
|
||||
case string s: writer.Write(s); break;
|
||||
case System.Collections.IEnumerable items:
|
||||
var all = items.Cast<object>().ToArray();
|
||||
writer.WriteArrayHeader(all.Length);
|
||||
foreach (var item in all) Put(ref writer, item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Bytes(object? value)
|
||||
{
|
||||
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
var writer = new MessagePackWriter(buffer);
|
||||
Put(ref writer, value);
|
||||
writer.Flush();
|
||||
return buffer.WrittenMemory.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] Wire(object version, object[] inputs, object[] nodes, int outputNode) =>
|
||||
Bytes(new object[] { version, inputs, new object[0], new object[] { new object[] { "o", outputNode } }, nodes });
|
||||
|
||||
private static void Refused(byte[] bytes, string rule, int node = -1)
|
||||
{
|
||||
var act = () => Decode(bytes);
|
||||
var thrown = act.Should().Throw<MessagePackSerializationException>().Which;
|
||||
var inner = thrown.InnerException.Should().BeOfType<ExprGraphException>().Which;
|
||||
inner.Rule.Should().Be(rule);
|
||||
inner.NodeIndex.Should().Be(node);
|
||||
thrown.ToString().Should().NotContain(Canary);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DecodeRefusesInvalidGraphs()
|
||||
{
|
||||
var x = new object[] { "x" };
|
||||
var cell = new object[] { "cellular3", new object[] { 0, 0, 0 }, 0 };
|
||||
|
||||
Decode(Wire(1, x, new[] { Input() }, 0)).Nodes.Should().HaveCount(1);
|
||||
|
||||
Refused(Wire(1, x, new object[] { Input(), new object[] { Canary + "_op", new object[] { 0 }, 0 } }, 1), "unknown-op", 1);
|
||||
Refused(Wire(1, x, new object[] { Input(), new object[] { "add", new object[] { 0 }, 0 } }, 1), "arity", 1);
|
||||
Refused(Wire(1, x, new object[] { Input(), new object[] { "neg", new object[] { 2 }, 0 } }, 1), "operand-order", 1);
|
||||
Refused(Wire(1, x, new object[] { Input(), new object[] { "neg", new object[] { 1 }, 0 } }, 1), "operand-order", 1);
|
||||
Refused(Wire(1, x, new object[] { Input(), new object[] { "pick", new object[] { 0 }, 0 } }, 1), "pick-range", 1);
|
||||
Refused(Wire(1, x, new object[] { Input(), cell, new object[] { "pick", new object[] { 1 }, 9 } }, 2), "pick-range", 2);
|
||||
Refused(Wire(1, x, new object[] { Input(), cell, new object[] { "neg", new object[] { 1 }, 0 } }, 2), "bare-multi-result", 2);
|
||||
Refused(Wire(1, new object[] { "x", "x" }, new[] { Input() }, 0), "duplicate-port-name");
|
||||
Refused(Wire(1, new object[] { Canary + "-bad" }, new[] { Input() }, 0), "port-name");
|
||||
Refused(Wire(2, x, new[] { Input() }, 0), "version");
|
||||
Refused(Wire(1, x, new[] { Input() }, 3), "output-node");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DecodeRefusesNilAndWrongShapes()
|
||||
{
|
||||
Refused(Bytes(new object[] { 1, new object[0] }), "shape");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ANilGraphIsNilLikeEveryOtherReferenceSlot()
|
||||
{
|
||||
MessagePackSerializer.Serialize<ExprGraph?>(null, Options).Should().Equal(new byte[] { 0xc0 });
|
||||
MessagePackSerializer.Deserialize<ExprGraph?>(new byte[] { 0xc0 }, Options).Should().BeNull();
|
||||
|
||||
var bytes = MessagePackSerializer.Serialize(new ExprHolder { Graph = null!, Scale = 3f }, Options);
|
||||
var decoded = MessagePackSerializer.Deserialize<ExprHolder>(bytes, Options);
|
||||
|
||||
decoded.Graph.Should().BeNull();
|
||||
decoded.Scale.Should().Be(3f);
|
||||
}
|
||||
|
||||
// [1, ["x"], [], [["o", 0]], [["input", [], 0]]]
|
||||
private static readonly byte[] Minimal =
|
||||
{
|
||||
0x95, 0x01, 0x91, 0xa1, 0x78, 0x90, 0x91, 0x92, 0xa1, 0x6f, 0x00, 0x91, 0x93, 0xa5, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x90, 0x00,
|
||||
};
|
||||
|
||||
private static byte[] Replace(byte[] bytes, int at, int length, params byte[] with) =>
|
||||
bytes.Take(at).Concat(with).Concat(bytes.Skip(at + length)).ToArray();
|
||||
|
||||
[Test]
|
||||
public void DecodeRefusesEveryEncodingButTheOneEncodeWrites()
|
||||
{
|
||||
Encode(Decode(Minimal)).Should().Equal(Minimal);
|
||||
|
||||
Refused(Replace(Minimal, 1, 1, 0xd2, 0, 0, 0, 1), "non-canonical"); // version as int32
|
||||
Refused(Replace(Minimal, 1, 1, 0xcc, 1), "non-canonical"); // version as uint8
|
||||
Refused(Replace(Minimal, 3, 2, 0xd9, 1, 0x78), "non-canonical"); // "x" as str8
|
||||
Refused(Replace(Minimal, 2, 1, 0xdc, 0, 1), "non-canonical"); // input list header as array16
|
||||
Refused(Replace(Minimal, 5, 1, 0xdd, 0, 0, 0, 0), "non-canonical"); // empty param list as array32
|
||||
Refused(Replace(Minimal, Minimal.Length - 1, 1, 0xcc, 0), "non-canonical"); // payload as uint8
|
||||
Refused(Replace(Minimal, 10, 1, 0xd0, 0), "non-canonical"); // output node as int8
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DecodeRefusesNestedShapesAndNilNames()
|
||||
{
|
||||
var node = new object[] { "input", new object[0], 0 };
|
||||
object[] Graph(object[]? param = null, object[]? output = null, object? node0 = null) =>
|
||||
new object[]
|
||||
{
|
||||
1,
|
||||
new object[] { "x" },
|
||||
param is null ? new object[0] : new object[] { param },
|
||||
new object[] { output ?? new object[] { "o", 0 } },
|
||||
new object[] { node0 ?? node },
|
||||
};
|
||||
|
||||
Decode(Bytes(Graph(param: new object[] { "k", 0 }, node0: new object[] { "param", new object[0], 0 })))
|
||||
.ParamNames.Should().Equal("k");
|
||||
|
||||
Refused(Bytes(Graph(param: new object[] { "k", 0, 0 })), "shape");
|
||||
Refused(Bytes(Graph(param: new object[] { "k" })), "shape");
|
||||
Refused(Bytes(Graph(output: new object[] { "o", 0, 0 })), "shape");
|
||||
Refused(Bytes(Graph(output: new object[] { "o" })), "shape");
|
||||
Refused(Bytes(Graph(node0: new object[] { "input", new object[0], 0, 0 })), "shape");
|
||||
Refused(Bytes(Graph(node0: new object[] { "input", new object[0] })), "shape");
|
||||
Refused(Bytes(new object[] { 1, new object?[] { null }, new object[0], new object[0], new object[0] }), "shape");
|
||||
Refused(Bytes(Graph(param: new object?[] { null, 0 })), "shape");
|
||||
Refused(Bytes(Graph(output: new object?[] { null, 0 })), "shape");
|
||||
Refused(Bytes(Graph(node0: new object?[] { null, new object[0], 0 })), "shape");
|
||||
Refused(Bytes(new object[] { 1, new object[0], new object[0], new object[0], new object[0], 0 }), "shape");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DepthIsReleasedAfterEachGraph()
|
||||
{
|
||||
var shallow = Options.WithSecurity(MessagePackSecurity.UntrustedData.WithMaximumObjectGraphDepth(4));
|
||||
var graphs = Enumerable.Range(0, 40).Select(_ => Sample()).ToArray();
|
||||
|
||||
var bytes = MessagePackSerializer.Serialize(graphs, shallow);
|
||||
var decoded = MessagePackSerializer.Deserialize<ExprGraph[]>(bytes, shallow);
|
||||
|
||||
decoded.Should().HaveCount(40);
|
||||
Encode(decoded[39]).Should().Equal(Encode(graphs[0]));
|
||||
|
||||
var tooDeep = Options.WithSecurity(MessagePackSecurity.UntrustedData.WithMaximumObjectGraphDepth(1));
|
||||
var act = () => MessagePackSerializer.Deserialize<ExprGraph[]>(bytes, tooDeep);
|
||||
act.Should().Throw<Exception>();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue