Merge origin/main into hands/variants-c2a
This commit is contained in:
commit
76e449f33e
23 changed files with 2613 additions and 813 deletions
|
|
@ -1,12 +1,10 @@
|
|||
# CultCache publication: the cache owns "published once", and order stays data
|
||||
|
||||
Status, 2026-09-30: Cuts 1-2 landed on `main` at `2a1b8a8`, from `hands/cultcache-pub-c1c2` at `5fb3fca`.
|
||||
Soul verdict: merge. Suites green: Caching 323, Networking 295, Mesh 262. Cut 3 is in Hands on
|
||||
`hands/cultcache-pub-c3`, with Soul's Cuts 1-2 follow-ups folded in:
|
||||
- F4: pin `Held`'s publish-on-throw guard. This is needed before Cut 4 relies on sequence density; Stryker could
|
||||
not mutate `Held` (CS0165).
|
||||
- F3: pin observer removal on unsubscribe.
|
||||
- F1: the "getters run once" claim is false for conditional commits on variant stores.
|
||||
Status, 2026-09-29: Cuts 1-3 landed on `main`. Cut 3 merged at `ae8576d`. Imagination pass 4 re-took Cuts 4-6
|
||||
against `3bf1c0c`. Q-P5 is ruled (A). Cut 4 is ready for Hands.
|
||||
- F4, pinning `Held`'s publish-on-throw guard, is done: Cut 3 pinned it.
|
||||
- F3 (observer removal on unsubscribe) and F1 (the false "getters run once" claim for conditional commits on variant
|
||||
stores) were Cut 3 follow-ups; confirm against the Cut 3 merge.
|
||||
|
||||
Recorded:
|
||||
- F2, the Q-P2 hazard. A rethrown observer exception makes a committed write look cancelled or refused. The
|
||||
|
|
@ -40,6 +38,8 @@ exactly once. This document owns the means.
|
|||
the writer afterwards, and the observer stays subscribed. The commit stands.
|
||||
- **Q-P3 A:** a public `CultCache.AddJournal(...)`, with reentry guarded.
|
||||
- **Q-P4 A:** Cut 6, which drops stale changes per key on the wire, lands in this campaign.
|
||||
- **Q-P5 A (2026-09-29):** a primary that cannot log a committed change throws a typed `CultNetShardLogException`
|
||||
naming the shard and the burned sequence, after publication, not derived from `InvalidOperationException`.
|
||||
|
||||
## The finding that shapes this map
|
||||
|
||||
|
|
@ -113,6 +113,139 @@ throwaway worktree that no branch holds.
|
|||
| Q7 | Soul's P2 shape: a handler waits 1 s for another thread's write | Observed in time. | Not observed in time. |
|
||||
| Suites | Caching, Networking and Mesh | (green on main) | Caching fails `ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries`, and Networking **hangs** until the 900 s idle kill. Rerun with `--blame-hang-timeout 90s`: 286 passed, then the test host hung and was dumped. The blame sequence file was not extracted, so **the hung test is not named**. It is consistent with, but not proven to be, `SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock`, whose own comment says "disposal would hang too". |
|
||||
|
||||
## Imagination pass 4: Cut 4 re-taken on the landed journal
|
||||
|
||||
Cut 3 landed at `ae8576d`. Its Soul left two questions that Cut 4 had to settle before relying on the
|
||||
journal: (a) what a throwing journal means for the log, and (b) whether a journal needs the change's origin.
|
||||
Both have one coherent answer. The one fork, Q-P5, was about who hears of a log failure; the operator ruled it A (2026-09-29).
|
||||
|
||||
### Probe evidence (pass 4)
|
||||
|
||||
- Probe file: `Pub4ProbeTests.cs` (Networking tests). It was run on Yggdrasil from scratch commits:
|
||||
- `0d7397d`: main plus the probes;
|
||||
- `6ec2f73`: the same, merged with the branch `28248eb`;
|
||||
- `376cd74`: main plus probe C.
|
||||
- The commits survive on the Yggdrasil mirror. The scratch worktree is gone.
|
||||
- The probes report facts and assert nothing. `FlakyLogStore` is an in-memory `ICultNetShardMutationLogStore`
|
||||
that can refuse `Append` or run a hook inside `GetCompactedThrough`.
|
||||
|
||||
| # | Probe | main `3bf1c0c` | branch `28248eb` (queue) |
|
||||
|---|---|---|---|
|
||||
| A1 | Primary with a durable log. Put 1, Put 2 (its `Append` throws), Put 3. A replica pulls, then retries. | Writer gets `IOException`. The cache holds 1, 2 and 3, but **2 is never published** (2 of 3). Memory log `[1,2,3]`, durable `[1,3]`. The replica pull throws "log has a gap. Expected 2, received 3", and so does **every retry**: the replica is stuck for good. | **Writer sees nothing**: R3 swallowed the drainer's exception. 2 is not published. The replica is stuck in the same way. |
|
||||
| A2 | The same failure at the tail, then the primary restarts over the same cache and log store. | The replica applied 1. After restart, note 3 is **minted sequence 2**. The replica takes it, reports applied 2, and holds `1,3` against the primary's `1,2,3`: **silent divergence**, a reused sequence. | Same. |
|
||||
| A3 | A replica whose log `Append` throws once. | The pull throws and the cursor stays at 0. The retry converges (`1,2`), but the memory log is `[1,1,2]`: a duplicate, because memory is written before disk and kept as a `List`. | The pull "succeeds": the exception was swallowed and the cursor went to 2. The durable log is `[2]`, so **entry 1 is lost**, and a chained replica sees a gap. |
|
||||
| B1 | Primary with a file store. Another writer puts `pulled` into the file, the primary runs `PullAll`, and a replica pulls the log. | `pulled` is published but **not logged**. The replica gets nothing. | Logged. The replica converges on `pulled`. |
|
||||
| B2 | A store attached after the database exists. | Nothing is logged. | All three hydrated records are logged. |
|
||||
| S | `CreateShardSnapshotResponse` with a write landing between its document read and its sequence read (hooked in `GetCompactedThrough`). | The snapshot holds `[1]` at log sequence 2. The replica applies it and pulls, and still lacks 2: **divergence**. | Same. |
|
||||
| C | Compact through 2, restart, then Put 3. | Note 3 is **minted sequence 1**, and `GetLatestMutationLogSequence` returns 1, below the floor of 2. | (not run; the code is unchanged) |
|
||||
|
||||
What the probes establish:
|
||||
- A1, A2, S and C are pre-existing defects on main. Cut 4 inherits them and must not build on them: the
|
||||
resync path it relies on for (a) is itself broken by S and C.
|
||||
- On a replica, the pull loop already retries. The cursor advances only after an entry applies
|
||||
(`CultNetDatabase.cs:1038-1083`), so a replica needs no new mechanism. It needs only to keep the exception
|
||||
reaching that loop, which the branch's queue broke (A3).
|
||||
- Loads on a primary must be logged, or replicas diverge (B1).
|
||||
|
||||
### (a) A journal that cannot log a committed change: one answer
|
||||
|
||||
The facts:
|
||||
- The log store is a second durable store written after the cache's store. Without an atomic commit across
|
||||
the two, the log can lag the store. It must never lead: a replica would then apply a change the primary
|
||||
refused.
|
||||
- So lag is unavoidable, and it has to be detectable and repaired.
|
||||
- The protocol already has a repair, and nothing else needs to be invented.
|
||||
- "History the primary cannot serve" is `compacted_history`: `CultNetDatabaseServer.cs:189-201` answers
|
||||
`ResyncRequired`.
|
||||
- `CultNetShardReplication.cs:199-214` then snapshot-resyncs.
|
||||
|
||||
**Rule: a primary that cannot log a committed change burns that change's sequence and compacts through it.**
|
||||
|
||||
- In the database journal, on a primary, for each change that is not `NoLog`:
|
||||
1. Mint `S`.
|
||||
2. Build the entry: the wire message from the door, or `ToLogEntryMessage`.
|
||||
3. `Append` durably, **then** record it in memory. Disk goes first, so memory never serves what disk lacks.
|
||||
- If step 2 or 3 throws:
|
||||
- record nothing;
|
||||
- raise the database's in-memory floor for the shard to `S`;
|
||||
- try `store.CompactThrough(shard, S)`;
|
||||
- still set `_lastWriteSequence` to `S`, because the row was written at `S`;
|
||||
- carry on with the rest of the admission;
|
||||
- throw `CultNetShardLogException` once at the end of the journal call (Q-P5 A).
|
||||
- Every replica behind `S` is then told `compacted_history` and resyncs by snapshot. The sequence gap at `S`
|
||||
is never served, because the floor covers it.
|
||||
- This **replaces** the old §4 step 3 claim, "a serialization failure then mints nothing, so the log can never
|
||||
hold a gap". Minting nothing hides a replication gap behind a dense sequence. That is exactly A2's silent
|
||||
divergence.
|
||||
- **Replica:** there is no floor. The journal's exception reaches `ApplyShardLogResponseAsync` and the cursor
|
||||
does not advance. The next pull re-applies the entry: a fresh instance, so the door matches again, and the
|
||||
entry is re-recorded under the primary's sequence. The branch's absent-key direct log follows the same
|
||||
disk-first order.
|
||||
- **Required fixes** that the rule depends on, all in Cut 4:
|
||||
- `GetCompactedMutationLogSequence` (`CultNetDatabase.cs:597-602`) returns
|
||||
`max(store floor, in-memory floor)`.
|
||||
- `InitializeLogSequencesFromStore` (`:1363-1395`) resumes at
|
||||
`max(highest retained, store.GetCompactedThrough(shard)) + 1`. This is probe C.
|
||||
- `GetLatestMutationLogSequence` (`:607-616`) never reports below the floor.
|
||||
- `CreateShardSnapshotResponse` (`:624-670`) reads `GetLatestMutationLogSequence` **before** `SelectAll`, on
|
||||
both returns, `:645` and `:668`. The documents then reflect at least that sequence, and replaying later
|
||||
entries converges: they are whole-document puts and deletes, applied in order. This is probe S.
|
||||
- `RecordMutationLogEntry` (`:1331-1352`) writes disk first. The in-memory log becomes
|
||||
`SortedList<long, entry>` per shard, replacing by sequence as the durable store does. This fixes A3's
|
||||
duplicate.
|
||||
- **Residual, recorded rather than forked.** Suppose the store refuses both `Append` and `CompactThrough`, and
|
||||
then the process restarts.
|
||||
- The durable log does not know about the hole, so A2's tail reuse can recur.
|
||||
- Fixing that needs a durable write per mint, a sequence reservation, which doubles log I/O under the gate.
|
||||
- The thrown exception names both failures, so the outage is loud.
|
||||
- Refusing later writes ("fail closed") is not coherent. The database can refuse only its own doors, while
|
||||
bare cache writes are journaled regardless: that would be split authority.
|
||||
|
||||
### (b) A pulled or loaded change on a primary: no origin needed
|
||||
|
||||
- On a primary the log is the shard's **state history**, whoever wrote the state. B1 shows that leaving loads
|
||||
out of the log leaves replicas permanently diverged.
|
||||
- The only origins that change what is logged are already carried by the door table, keyed by instance
|
||||
identity and matched by shape. They are:
|
||||
- `Replica` (log the primary's entry);
|
||||
- `NoLog` (prediction, snapshot resync);
|
||||
- `Put`/`Delete` (the wire message);
|
||||
- `Predicts`/`Authoritative`.
|
||||
- A load or pull has no door, so on a primary it is logged like any unattributed commit. On a replica it logs
|
||||
nothing, like any local write. That is what the branch does, and B1 converges under it.
|
||||
- Cost:
|
||||
- B2: a store attached **after** the database logs its hydration.
|
||||
- `CultNetHost` opens the cache before building the database (`CultNetLocal.cs:146-152`), so production
|
||||
hydration is not journaled.
|
||||
- A re-pull admits only the records that changed. In B1 the seed was not re-logged.
|
||||
- `AddJournal` keeps its signature. If a future consumer (audit, say) needs "load or commit", the fact goes on
|
||||
`CultCacheDocumentChange` as an additive property, as `Sequence` did. The cache already knows it: `Admit`'s
|
||||
`source != null` at `CultCache.cs:2420`. It never needs a new `AddJournal` shape.
|
||||
- This is recorded, not forked: no consumer needs it today.
|
||||
|
||||
### The two recorded hazards, settled for Cut 4
|
||||
|
||||
- **Q-P2 typed-exception hazard.** Cut 4 makes it frequent. Every cache write on a primary with a durable log
|
||||
now does log I/O inside its admission.
|
||||
- AetheriaEve opts into durable logs (`Aetheria.State/AetheriaStateNode.cs:144`).
|
||||
- Its `AetheriaHangarCommandJournal.ValidateAsync` writes an envelope (`Program.cs:6509`) inside the `catch
|
||||
(InvalidOperationException)` at `Program.cs:3722`.
|
||||
- A log failure surfacing as `InvalidOperationException` would therefore produce a "denied" receipt for a
|
||||
committed envelope.
|
||||
- Q-P5 A (ruled): the writer gets a typed `CultNetShardLogException` that **does not derive from
|
||||
`InvalidOperationException`**.
|
||||
- **Journal deadlock (the Cut 3 note).** The database journal waits only on `_logGate` and on the log store's
|
||||
own lock.
|
||||
- Lock order: cache gate, then `_logGate`, then the log store's lock.
|
||||
- Invariant: **no code holding `_logGate` calls the cache, and no log store calls the cache or waits on a
|
||||
thread that does.**
|
||||
- Readers that take `_logGate` read only log state: `GetMutationLog`, `LastWriteSequence`, `CurrentAsOf`.
|
||||
Readers already inside a cache read, such as the selection evaluator's `ordinalOf`, nest `_logGate` under
|
||||
the cache gate, which is the same order.
|
||||
- The store rule goes into the `ICultNetShardMutationLogStore` XML doc (`CultNetShardMutationLogStorage.cs:13-34`),
|
||||
because a user store runs under the cache's gate.
|
||||
- A log store that writes the cache already throws, through Cut 3's guard. Cut 4 pins it with a test.
|
||||
|
||||
## Authority map
|
||||
|
||||
- **Owner.** `CultCache`. The hold that mints a Sequence range also:
|
||||
|
|
@ -287,12 +420,9 @@ The database is rebuilt on the journal. It has no queue.
|
|||
- Soul's P6 cannot happen: the prediction is recorded before
|
||||
`PutPredictedAsync` returns.
|
||||
3. Primacy.
|
||||
- On a shard primary, where the door is not `NoLog` (prediction, snapshot):
|
||||
build the log entry, including the wire message from a `Put`/`Delete`
|
||||
door or `ToLogEntryMessage`, and **only then** mint the per-shard
|
||||
sequence and append it to memory and to the durable store. A
|
||||
serialization failure then mints nothing, so the log can never hold a
|
||||
gap.
|
||||
- On a shard primary, where the door is not `NoLog`: mint, build, append disk-first and record, with a failed
|
||||
append burning the sequence and compacting through it. See "(a) A journal that cannot log a committed
|
||||
change" in Imagination pass 4; the earlier claim that a serialization failure mints nothing is withdrawn.
|
||||
- On a replica, record the primary's entry from the `Replica` door,
|
||||
under its own sequence.
|
||||
- For a local write on a replica, log nothing.
|
||||
|
|
@ -485,96 +615,152 @@ They are the proof that no scheduler has come back.
|
|||
|
||||
### Cut 4. Salvage the branch and rebuild CultNetDatabase on the journal (networking)
|
||||
|
||||
- **4a (history).** `git merge origin/hands/cultnet-publish-commits` into the
|
||||
cut branch. It is clean. The merge carries the tests; the queue dies in 4b.
|
||||
Self may choose a squash-salvage instead, with the same content.
|
||||
- **4b (subtraction, then behaviour).**
|
||||
- **Deletes first**, at branch `28248eb` `CultNetDatabase.cs`:
|
||||
- `_parked`, `_ready`, `_nextSequence` and `_draining` (`:502-505`);
|
||||
- the handshake at `:530`;
|
||||
- `OnCacheChange`'s queue body (`:1083-1135`);
|
||||
- `PendingChange` (`:1265-1275`);
|
||||
- `_predictionGate` and `HasPrediction` (`:506-507`, `:1209-1213`) and
|
||||
their call sites (`:916`, `:1574`);
|
||||
- the logging in `Deliver` (`:1168-1201`).
|
||||
- **Moves.** Logging, predictions and door consumption move into the journal
|
||||
(§4). `Deliver` becomes the publisher that reads the stash.
|
||||
- **Changes.**
|
||||
- Door flags: `Predicts` and `Authoritative`.
|
||||
- The snapshot removal door sets `Removal`.
|
||||
- The in-memory log becomes a `SortedList`.
|
||||
- **Keeps.** Door identity and the shape rule, primacy, `_logGate` for
|
||||
readers, and the absent-key delete's direct log.
|
||||
- **4a (history).** `git merge origin/hands/cultnet-publish-commits` into the cut branch from main
|
||||
`3bf1c0c`.
|
||||
- It is clean: the probe merge `6ec2f73` auto-merged `NetworkingTests.cs` and the composition doc, then
|
||||
built and ran.
|
||||
- The merge brings the tests. The queue dies in 4b.
|
||||
- Self may squash-salvage instead, with the same content.
|
||||
- **4b (subtraction first).** Delete at branch `28248eb` `CultNetDatabase.cs`:
|
||||
- `_parked`, `_ready`, `_nextSequence` and `_draining` (`:502-505`);
|
||||
- `_predictionGate` (`:506-507`);
|
||||
- the Watch subscription and handshake (`:529-531`);
|
||||
- `OnCacheChange` (`:1080-1135`);
|
||||
- `PendingChange` (`:1265-1275`);
|
||||
- `HasPrediction` (`:1209-1213`) and its calls (`:916`, `:1574`);
|
||||
- the logging and prediction bookkeeping in `Deliver` (`:1137-1201`).
|
||||
|
||||
Main's `PublishCacheUpdate` / `OnUpdate +=` (`:505`, `:1157`, `:1161-1191`) is already gone on the branch.
|
||||
Hands confirms it is absent after the merge.
|
||||
- **4c (the journal).** Register the Watch observer first and the journal second (`AddJournal`), in the
|
||||
constructor. Per admission, under the cache gate, per change in Sequence order:
|
||||
1. `TakeDoor(change)`, keeping the branch's shape rule (`:1219-1234`).
|
||||
2. Resolve the shard and kind. Predictions: a `Predicts` door adds its key. An `Authoritative` door
|
||||
(`ApplyPutAsync`, replica put) holding a prediction for the key makes the kind `Reconciled` and removes
|
||||
the key.
|
||||
3. Log, per §(a):
|
||||
- a replica door records the primary's entry, disk first;
|
||||
- a primary change that is not `NoLog` mints, builds, appends and records, with failure going to the
|
||||
floor;
|
||||
- anything else logs nothing.
|
||||
4. Stash `(cache Sequence → CultNetDatabaseChange)` under `_logGate`.
|
||||
5. If step 3 failed, throw once at the end of the call, after all changes are handled (Q-P5).
|
||||
- **4c, publication.** The Watch observer removes the stash entry for `change.Sequence` and publishes it. A
|
||||
missing stash means the change was admitted before this database existed, so it is ignored. `Dispose`
|
||||
removes the journal and the observer and clears the stash.
|
||||
- **4d (the §(a) fixes).**
|
||||
- The floor: `GetCompactedMutationLogSequence`, `GetLatestMutationLogSequence`, and resume in
|
||||
`InitializeLogSequencesFromStore`.
|
||||
- Disk-first `RecordMutationLogEntry`.
|
||||
- A `SortedList` per shard.
|
||||
- The snapshot reads its sequence first (`:660` moves below `:668`'s read).
|
||||
- `_logGate` around `_lastWriteSequence`/`_nextLogSequences` readers (`:1302`, `:1314`, `:1325`).
|
||||
- **Door changes.**
|
||||
- Flags: `Predicts` and `Authoritative`.
|
||||
- The snapshot removal door (branch `:733`) sets `Removal = true`.
|
||||
- The replica absent-key delete logs directly and publishes nothing (branch `:1612-1621`).
|
||||
- Doors neither log nor publish. They register context, write the cache, and dispose the registration.
|
||||
- **Verification.**
|
||||
- Every salvaged test passes:
|
||||
- `CultNetPublishesCommitsTests` (13);
|
||||
- `CultNetPublicationOrderTests`: S1, S1b, S2, S3, S4, S6, S7,
|
||||
log-before-publish, and in-place re-upsert.
|
||||
- Soul's P1-P7 from `SoulPub3Tests.cs` are committed, all passing. P2 passes
|
||||
because nothing orders delivery.
|
||||
- Every salvaged test passes: `CultNetPublishesCommitsTests` (13) and `CultNetPublicationOrderTests` (S1,
|
||||
S1b, S2, S3, S4, S6, S7, log-before-publish, in-place re-upsert).
|
||||
- Soul pass 3's P1-P7 are committed and passing.
|
||||
- The pass 4 probes become tests named for the rule:
|
||||
- **A1:** a refused primary `Append` publishes the change. The writer gets `CultNetShardLogException`,
|
||||
which names the shard and `S`. A replica pull answers `compacted_history`, and a snapshot resync
|
||||
converges.
|
||||
- **A2:** the same at the tail with a restart: the replica converges after resync, and no sequence is
|
||||
reused. The store's `CompactThrough` succeeded.
|
||||
- **A3:** a refused replica `Append` leaves the cursor unchanged. The retry converges, and the memory log
|
||||
holds `[1,2]`.
|
||||
- **B1:** a pull on a primary is logged, and the replica converges.
|
||||
- **S:** a snapshot racing a write converges.
|
||||
- **C:** after compacting through 2 and restarting, the next mint is 3.
|
||||
- New tests:
|
||||
- a reentrant `database.PutAsync` from inside a database handler is logged
|
||||
with its context and published once;
|
||||
- a reentrant `ApplyShardLogResponseAsync` from inside a handler keeps the
|
||||
replica log in primary order;
|
||||
- a primary serialization failure mints no log sequence.
|
||||
- Commit latency with `CultNetFileShardMutationLogStore`, main against Cut 4.
|
||||
It is reported, not gated.
|
||||
- Negative grep: `rg -n "_parked|_ready|_draining|_nextSequence|_predictionGate|HasPrediction" src/GameCult.Networking`
|
||||
- a reentrant `database.PutAsync` from inside a database handler is logged with its context and published
|
||||
once;
|
||||
- a reentrant `ApplyShardLogResponseAsync` from inside a handler keeps the replica log in primary order;
|
||||
- a log store whose `Append` writes the cache fails with Cut 3's reentry guard, wrapped, and the admission
|
||||
stands.
|
||||
- Commit latency with `CultNetFileShardMutationLogStore`, main against Cut 4. Also measure a `CultMesh` node
|
||||
with `EnableDurableShardLogs`, where every bookkeeping write is now logged. Report it; do not gate on it.
|
||||
- Negative grep: `rg -n "_parked|_ready|_draining|_nextSequence|_predictionGate|HasPrediction|PublishCacheUpdate" src/GameCult.Networking`
|
||||
returns nothing.
|
||||
- Review check: no `_cache.` call inside a `lock (_logGate)` block.
|
||||
- The `4562340` tests stay green.
|
||||
- Stryker on the diff.
|
||||
- **Ledger.** Against the branch, about −120 and +60.
|
||||
- Test runs use `--blame-hang-timeout 2m`.
|
||||
- Stryker on the diff: dropping the floor raise, memory-before-disk, and the snapshot read order must each
|
||||
kill a test.
|
||||
- **Docs.**
|
||||
- Composition doc `:50-53`: replace the "streams derive from `OnUpdate`" sentence. CultNet derives from
|
||||
`Watch` plus one journal, and the log is written in cache order.
|
||||
- A new paragraph: a primary that cannot log a change compacts through it.
|
||||
- The log-store rule under Locking.
|
||||
- **Blockers.** None. Q-P5 is ruled A.
|
||||
- **Ledger.** Against the branch, about −130 and +95. The extra over the old estimate is the floor, the
|
||||
disk-first order, the `SortedList`, the snapshot order, and the exception type.
|
||||
|
||||
### Cut 5. Database fan-out isolation and Sequence (networking, behaviour)
|
||||
|
||||
- **Deletes first.** The database's `Subject<object> _changes` (main `:487`).
|
||||
- **Deletes first.** The database's `Subject<object> _changes` (main `:485`, branch `:487`) and its
|
||||
`Dispose` (`:1158`).
|
||||
- **Adds.**
|
||||
- The isolated observer array.
|
||||
- `CultNetDatabaseChange<T>.Sequence` (main `:319-371`), set by the journal.
|
||||
- The isolated observer array, the same pattern as the cache's `Register`/`Publish`
|
||||
(`CultCache.cs:1929-1944`, `:2470-2503`), behind `Watch<T>`, `WatchAllChanges` and the rest (`:1089-1145`).
|
||||
- `CultNetDatabaseChange<T>.Sequence` (`:319-371`), as an optional constructor argument. The journal's stash
|
||||
sets it from the cache change. A change with no cache change behind it carries 0.
|
||||
- **Docs.** The binary-signature note, as the composition doc gave for `CultCacheDocumentChange` (`:57-58`).
|
||||
- **Verification.**
|
||||
- With a rethrowing handler and one throwing database subscriber, the other
|
||||
subscriber and a live subscription-server peer receive every change.
|
||||
- With a rethrowing handler and one throwing database subscriber, the other subscriber and a live
|
||||
subscription-server peer receive every change. The writer gets the exception after delivery (Q-P2).
|
||||
- The disposed-database test.
|
||||
- `Sequence` equals the cache change's.
|
||||
- **Ledger.** About −10 and +30.
|
||||
- **Blockers.** Cut 4.
|
||||
- **Ledger.** About −10 and +35.
|
||||
|
||||
### Cut 6. Stale protection on wire projections (networking, behaviour)
|
||||
### Cut 6. Stale protection on wire projections (networking, behaviour; Q-P4 A)
|
||||
|
||||
This is a separate cut that closes a pre-existing hole. Composition doc
|
||||
`:50-53` says the CultNet streams "have no ordering guarantee and no stale
|
||||
protection", and the branch's total order was reaching for this.
|
||||
- **The rule.** A projection applies a change to a key only if its `Sequence` is above what the projection
|
||||
last applied for that key. A snapshot or reconcile applies everything at or below the cache sequence it read
|
||||
**before** reading documents.
|
||||
- That read is `Cache.GetWithSequence(<any key>).Sequence`, the branch's idiom (branch `:531`). No new API
|
||||
is needed.
|
||||
- **Subscription server** (`CultNetDatabaseSubscriptionServer.cs`):
|
||||
- `SubscriptionProjectionState` (`:630`) gains `long Floor` and `Dictionary<string, long> Applied`.
|
||||
- The single-row fast path in `Watch` (`:296-340`) reads the change's `Sequence` (reflection, like `Key`).
|
||||
Under `_lifecycleGate` it drops the change if `Sequence <= max(Floor, Applied[key])`. Otherwise it applies
|
||||
the change and records it. Removals are included.
|
||||
- `Reconcile` (`:347`) and the subscribe snapshot (`:168-181`) read `seq0` before `CreateProjectedSnapshot`
|
||||
(`:402`), set `Floor = seq0`, and clear `Applied`.
|
||||
- This is sound: every change applied earlier was applied under the same gate, after its admission, so
|
||||
its `Sequence` is at most `seq0`.
|
||||
- Hop and unauthorized paths already reconcile from current state, and they set the floor too.
|
||||
- **`CultNetDatabaseServer.CreateSubscription`** (`:501-513`):
|
||||
- A per-subscription `(Floor, Applied)` under a per-subscription lock, held across check, record and send.
|
||||
- `HandleSubscribeAsync`'s snapshot (`:362-369`) takes `seq0` first and sets the floor.
|
||||
- **Verification.**
|
||||
- Two threads write one key, with a blocking cache observer that forces the inverted delivery (Q3 shape).
|
||||
On both servers, the peer's last state for the key equals the cache's.
|
||||
- A removal overtaken by an older upsert leaves the key absent at the peer.
|
||||
- The negative check: without the drop, both tests fail.
|
||||
- **Blockers.** Cut 5, which provides `Sequence` on database changes.
|
||||
- **Ledger.** About +45.
|
||||
|
||||
- **The rule.** A projection sends a record only if the change's `Sequence` is
|
||||
above the Sequence it last applied for that key.
|
||||
- The subscription server applies it under `_lifecycleGate`, keyed in
|
||||
`SubscriptionProjectionState` next to `DeliveredBySourceRecordKey`
|
||||
(`CultNetDatabaseSubscriptionServer.cs:446`).
|
||||
- `CultNetDatabaseServer.CreateSubscription` (`:501-512`) applies it under a
|
||||
per-subscription lock.
|
||||
- `Reconcile` (`:347`) stamps each key it reads with a per-key atomic
|
||||
`GetWithSequence`.
|
||||
- **Verification.** Two threads write one key with a blocking observer that
|
||||
forces the inverted delivery (Q3 shape). The peer's last state for the key
|
||||
equals the cache's, on both servers.
|
||||
- **Operator.** Q-P4 asks whether this cut belongs here or is recorded.
|
||||
- **Ledger.** About +40.
|
||||
|
||||
### Salvage from `hands/cultnet-publish-commits`
|
||||
### Salvage from `hands/cultnet-publish-commits` (revised)
|
||||
|
||||
- **Keep:**
|
||||
- both new test files;
|
||||
- the `NetworkingTests.cs` edits (`:1528`, `:2257`);
|
||||
- the door-shape rule;
|
||||
- both test files;
|
||||
- the `NetworkingTests.cs` edits;
|
||||
- the door table and the shape rule;
|
||||
- "replicas never mint";
|
||||
- log-before-publish;
|
||||
- "the snapshot logs nothing";
|
||||
- the rewritten composition-doc paragraph, rewritten again by Cut 3 to name
|
||||
the journal as the log's order source.
|
||||
- **Commit** Soul pass 3's P1-P7: P3 in Cut 2, P4 in Cut 1, and the rest in
|
||||
Cut 4.
|
||||
- **Drop:** the reorder buffer, the drainer, `_predictionGate`, and logging
|
||||
from the observer.
|
||||
- "loads and pulls on a primary are logged" (B1);
|
||||
- the composition-doc paragraph, rewritten to name the journal.
|
||||
- **Drop:**
|
||||
- the reorder buffer and the drainer;
|
||||
- `_predictionGate`;
|
||||
- logging from the observer;
|
||||
- the swallowed exception path (A1 and A3 on the branch).
|
||||
|
||||
## Subtraction ledger (estimate)
|
||||
|
||||
|
|
@ -583,66 +769,28 @@ protection", and the branch's total order was reaching for this.
|
|||
| 1 | ~15 | ~45 | none |
|
||||
| 2 | ~5 | ~30 | the cache no longer uses R3 `Subject` |
|
||||
| 3 | 0 | ~50 | one public method (Q-P3) |
|
||||
| 4 | ~120 vs branch | ~60 | the merge brings +1109/−243, of which about 780 are tests |
|
||||
| 5 | ~10 | ~30 | the database `Subject` goes, and one public property |
|
||||
| 6 | 0 | ~40 | none |
|
||||
| 4 | ~130 vs branch | ~95 | the merge brings +1109/-243, of which about 780 are tests; the floor, disk-first order, `SortedList`, snapshot order and `CultNetShardLogException` are the extra over the first estimate |
|
||||
| 5 | ~10 | ~35 | the database `Subject` goes, and one public property |
|
||||
| 6 | 0 | ~45 | none |
|
||||
|
||||
- **Source, net against main:** about +200.
|
||||
- **Source against the branch:** about −60, and no scheduler anywhere.
|
||||
- **Source, net against main:** about +245.
|
||||
- **Source against the branch:** no scheduler anywhere.
|
||||
- The budget is pressure, not a metric.
|
||||
|
||||
## Operator questions
|
||||
|
||||
- **Q-P1. Order.**
|
||||
- **A. Order as data** (the `4562340` ruling, kept).
|
||||
- Every change is published exactly once, on its writer's thread, before
|
||||
the write returns, and carries `Sequence`.
|
||||
- There is no cross-thread delivery order.
|
||||
- The shard log is in cache order because the journal writes it under the
|
||||
gate.
|
||||
- Wire projections drop stale changes by Sequence (Cut 6).
|
||||
- **B. Totally ordered delivery in the cache** (a turnstile, which is Soul's
|
||||
stated direction). It was built as `bcae483`, deleted for deadlock as
|
||||
`4562340`, and reproduced by this pass: the networking suite hangs.
|
||||
Making it safe needs a rule that nobody holding a lock an observer takes
|
||||
may write the cache, and the subscription server itself breaks that rule.
|
||||
- **C. A dedicated delivery thread or drainer** (the branch). It was refuted
|
||||
by Soul pass 3: F3 and F4.
|
||||
- **Recommended: A.** Only A delivers exactly-once, before-return and
|
||||
no-deadlock together. The one consumer that needs order gets it where order
|
||||
is decided.
|
||||
- **Q-P2. An observer whose exception escapes** (a fail-fast R3 handler, or a
|
||||
custom observer).
|
||||
- **A.** Rethrow it to the writer after every observer has run, and keep the
|
||||
observer subscribed.
|
||||
- **B.** Report it and unsubscribe.
|
||||
- **C.** Route it to R3's unhandled handler only.
|
||||
- **Recommended: A.**
|
||||
- It matches the `OnUpdate` policy and R3's `OnErrorResume`.
|
||||
- An escape only happens when the owner asked for fail-fast.
|
||||
- B turns one bad change into permanent deafness.
|
||||
- C sends the exception back to the handler that just rethrew it.
|
||||
- The commit stands under every option.
|
||||
- **Q-P3. The journal's surface.**
|
||||
- **A.** A public `IDisposable CultCache.AddJournal(Action<IReadOnlyList<CultCacheDocumentChange<object>>>)`,
|
||||
guarded so that entering the cache from it throws.
|
||||
- **B.** An internal method, with `InternalsVisibleTo("GameCult.Networking")`.
|
||||
The comment at `CultCache.cs:214` shows Networking was deliberately kept
|
||||
without it.
|
||||
- **Recommended: A.**
|
||||
- The contract is small, and the dangerous misuse, reentry, is enforced
|
||||
rather than documented.
|
||||
- An ordered admission journal is a legitimate library capability: audit,
|
||||
replication and derived indexes.
|
||||
- B couples two assemblies to hide one method.
|
||||
- **Q-P4. Cut 6 now or later?**
|
||||
- **A.** Land it in this campaign.
|
||||
- **B.** Record it.
|
||||
- **Recommended: A.** With every commit now published, two writers of one key
|
||||
on two threads can leave a peer holding the older state. That was already
|
||||
true for loads; commits make it common.
|
||||
- Q-P1..Q-P4 are ruled (see Rulings). Their option analysis is in the map's git history.
|
||||
|
||||
Not forks, recorded so they are not re-asked:
|
||||
- **Q-P5 A (ruled 2026-09-29): a primary that cannot log a committed change throws a typed
|
||||
`CultNetShardLogException` naming the shard and the burned sequence, after publication, not derived from
|
||||
`InvalidOperationException`.**
|
||||
- The commit stands, the change is published, and replicas behind it snapshot-resync (Imagination pass 4 (a)).
|
||||
- The writer hears of it. AetheriaEve `Program.cs:3722` cannot mistake it for a denial.
|
||||
|
||||
Recorded, not forked:
|
||||
- (b): no origin on the journal. Loads and pulls are logged on primaries.
|
||||
- (a)'s restart residual when the store refuses both writes.
|
||||
- The replica keeps retry-by-cursor.
|
||||
- A throwing getter refuses loads too.
|
||||
- Reentrant writes keep main's depth-first delivery. Order is data.
|
||||
- Journal I/O under the gate follows the store precedent.
|
||||
|
|
@ -651,8 +799,6 @@ Not forks, recorded so they are not re-asked:
|
|||
|
||||
- **`cultmesh-py` watch.** It has no isolation and no Sequence
|
||||
(`node.py:1059-1065`).
|
||||
- **`RecordMutationLogEntry` memory-before-disk.** A throwing durable `Append`
|
||||
leaves memory ahead of disk. This predates the campaign.
|
||||
- **Durable log whole-file rewrite.** `CultNetShardMutationLogStorage.cs:83-89`
|
||||
rewrites the whole file per `Append`. It is a monolith, and under the gate
|
||||
its cost is now every writer's.
|
||||
|
|
@ -662,3 +808,9 @@ Not forks, recorded so they are not re-asked:
|
|||
serialized delivery was built, deadlocked, and was deleted". The Soul pass 3
|
||||
direction re-proposed it anyway. The Soul brief template should tell Soul to
|
||||
check `git log -S` for a proposed mechanism before recommending it.
|
||||
- **A chained replica after a snapshot.** A replica that snapshot-resyncs has no log entries below the snapshot
|
||||
sequence. It should raise its own floor to that sequence, so a replica chained on it resyncs instead of seeing
|
||||
a gap (`ApplyShardSnapshotResponseAsync` `:675-736`). It predates this campaign.
|
||||
- **AetheriaEve `Program.cs:3722`.** It reads a committed write's `InvalidOperationException` as a denial. Under
|
||||
Q-P5 A, Cut 4's log failures cannot reach it. Observer and `OnUpdate` exceptions still can, so the Cuts 1-2
|
||||
recording stands.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -33,6 +33,8 @@ 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) from the same
|
||||
directory, so ship both files.
|
||||
- `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.
|
||||
|
|
|
|||
|
|
@ -19,3 +19,23 @@ permutation and scale as `snoise(float3)`.
|
|||
- Copyright 2011 Ashima Arts.
|
||||
- Upstream: <https://github.com/ashima/webgl-noise>
|
||||
- License: MIT.
|
||||
|
||||
## Phacelle Noise
|
||||
|
||||
`math.phacelle` and `cultmath_phacelle` follow Rune Skovbo Johansen's Phacelle
|
||||
noise (Shadertoy `t3dyWl`; erosion filter `wXcfWn`; blog, March 2026), read
|
||||
from the C# port `PhacelleNoise` in `lpmitchell/AdvancedTerrainErosion`
|
||||
(`AdvancedTerrainErosion.cs`, converted to burstable C# by Luke Mitchell,
|
||||
2026). The cell weight, the phase blend and the normalization follow it. The
|
||||
3D-cell generalisation, the caller-supplied stripe wave vector, the exact
|
||||
gradient and the exact pruning are new here.
|
||||
|
||||
- Copyright (c) 2025 Rune Skovbo Johansen.
|
||||
- Upstream: <https://github.com/lpmitchell/AdvancedTerrainErosion>
|
||||
- License: MPL-2.0 (<https://mozilla.org/MPL/2.0/>). MPL-2.0 is file-level, so it
|
||||
covers exactly the files that carry the port and nothing else:
|
||||
`src/CultMath/math.Phacelle.cs`, `src/CultMath/CultPhasor.cs`,
|
||||
`shaders/CultMath.Phacelle.hlsl` and the Unity copy of that include. Each
|
||||
carries the MPL-2.0 header and this attribution. The rest of CultMath is MIT,
|
||||
so the `GameCult.Math` NuGet package declares `MIT AND MPL-2.0` and ships this
|
||||
file. The Unity package as a whole is MPL-2.0.
|
||||
|
|
|
|||
|
|
@ -128,8 +128,8 @@ whose HLSL return type is a plain struct of `float`/`floatN`/`int`/`intN`
|
|||
fields is compared to its C# counterpart field by field, recursing into each
|
||||
field's own components, down to bit-for-bit scalars, matching each leaf's
|
||||
declaration path (field names, not just position) and declared type as well
|
||||
as its value. `CultCellular` (`math.cs`, `cellular`) is the first and, for
|
||||
now, only consumer. A struct return with a mismatched, reordered-by-name, or
|
||||
as its value. `CultCellular` (`math.cs`, `cellular`) and `CultPhasor`
|
||||
(`math.Phacelle.cs`, `phacelle`) are its consumers. A struct return with a mismatched, reordered-by-name, or
|
||||
retyped field does not get a second comparison path or a shape of its own;
|
||||
it fails the same walk that already handles a bare vector return.
|
||||
|
||||
|
|
@ -264,6 +264,41 @@ together, laid out as `float4(gradient.xyz, value.w)`, with no value-only twin
|
|||
which is the right domain for `pcg3d`/`pcg4d`'s O(1) hash inputs but would
|
||||
turn an unclamped octave count into a multi-billion-iteration loop for these
|
||||
two mirrors.
|
||||
- `phacelle(float3 p, float3 side, float offset, float normalization)` is Rune
|
||||
Skovbo Johansen's Phacelle noise ("phase + cell": a stripe pattern built by
|
||||
blending one cosine/sine wave per jittered cell), generalised to 3D cells. It
|
||||
returns `CultPhasor { cos, sin }`, each `(∇, value)`. `side` is the stripe
|
||||
wave vector, whose length is 2π times the stripe frequency; in 3D the
|
||||
perpendicular to the flow is not unique, so the caller chooses it (on a unit
|
||||
sphere, `cross(p̂, flow)`). `offset` is the phase in cycles. It visits 4×4×4
|
||||
cells with `pcg3d`-hashed jitter in `[-0.5, 0.5)`, weights each wave by
|
||||
`max(0, exp(-2d²) - 0.01111)`, and normalizes the blended phasor as upstream
|
||||
does (length 1 where the raw length is at least `1 - normalization`, scaled
|
||||
by `1 / (1 - normalization)` below). The gradient is exact, weight
|
||||
derivatives and normalization included, rather than upstream's
|
||||
`∇cos ≈ -sin·side`, which ignores the weight gradient. It is discontinuous
|
||||
where the raw length crosses the normalization threshold and where a cell's
|
||||
weight leaves its support (d² = 2.24995, distance 1.49998); the value is
|
||||
continuous at both. At the support edge `∇w` jumps from `-4·0.01111·v` to 0,
|
||||
a per-cell jump of length `0.0444·|v|` (0.0667 at `|v| = 1.5`); measured over
|
||||
random points, the output gradient jumps by a median 1.5% of its length and
|
||||
at most about 27%. `normalization` is meant to lie in `[0, 1]`: 1 or more
|
||||
saturates (every output is stretched to length 1), a negative value scales
|
||||
every output down by `1 / (1 - normalization)`, and the normalized gradient
|
||||
scales as `1 / |I|` for the blended phasor `I`, so it grows where the blend
|
||||
nearly cancels. The total weight `W` is positive for every `pcg3d` input (the
|
||||
smallest found by hill-climbing is 0.056); `W = 0`, and a NaN output, needs
|
||||
adversarial jitter no hash yields.
|
||||
Cells whose per-axis lower bound `Σ max(0, |local - g| - 0.5)²` is at least
|
||||
2.25 lie outside the support and have weight exactly 0, so they are skipped,
|
||||
which changes no output bit. Cells whose bound sits just under 2.25 can still
|
||||
carry a nonzero weight, so the cut cannot be lowered; `PhacelleTests` pins
|
||||
the cut with points next to such cells, against an internal overload with the
|
||||
prune disabled, and the HLSL mirror test pins it on the shader side. The prune
|
||||
keeps about 45 of the 64 cells on average (a saving of about 30%); about 14
|
||||
have a nonzero weight. The port sits in its own MPL-2.0 files
|
||||
(`math.Phacelle.cs`, `CultPhasor.cs`, `CultMath.Phacelle.hlsl`); see
|
||||
`THIRD-PARTY-NOTICES.md`. No 2D variant exists; nothing consumes one.
|
||||
|
||||
Integer hashing uses the PCG hashes from Jarzynski and Olano, "Hash Functions
|
||||
for GPU Rendering" (JCGT 9(3), 2020): `pcg(uint)` is O'Neill's RXS-M-XS 32/32
|
||||
|
|
|
|||
|
|
@ -78,7 +78,9 @@ $requiredTemplateFiles = @(
|
|||
"Runtime\UnityBridge\UnityConversions.cs",
|
||||
"Runtime\UnityBridge\UnityConversions.cs.meta",
|
||||
"Shaders\CultMath.hlsl",
|
||||
"Shaders\CultMath.hlsl.meta"
|
||||
"Shaders\CultMath.hlsl.meta",
|
||||
"Shaders\CultMath.Phacelle.hlsl",
|
||||
"Shaders\CultMath.Phacelle.hlsl.meta"
|
||||
)
|
||||
foreach ($relativePath in $requiredTemplateFiles) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $templateRoot $relativePath) -PathType Leaf)) {
|
||||
|
|
|
|||
99
packages/cultmath/shaders/CultMath.Phacelle.hlsl
Normal file
99
packages/cultmath/shaders/CultMath.Phacelle.hlsl
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
#ifndef CULTMATH_PHACELLE_HLSL
|
||||
#define CULTMATH_PHACELLE_HLSL
|
||||
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|
||||
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the
|
||||
// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke
|
||||
// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact
|
||||
// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after
|
||||
// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md).
|
||||
|
||||
// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit
|
||||
// against CultMath.CultPhasor, same field order.
|
||||
struct CultPhasor
|
||||
{
|
||||
float4 cos;
|
||||
float4 sin;
|
||||
};
|
||||
|
||||
// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave
|
||||
// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors
|
||||
// the C# body operation for operation; the C#-only internal overload that switches the prune off
|
||||
// has no shader twin.
|
||||
CultPhasor cultmath_phacelle(float3 p, float3 side, float offset, float normalization)
|
||||
{
|
||||
float3 cell = floor(p);
|
||||
float3 local = p - cell;
|
||||
float phaseOffset = offset * CULTMATH_TAU;
|
||||
float sumW = 0.0;
|
||||
float sumCos = 0.0;
|
||||
float sumSin = 0.0;
|
||||
float3 gradW = float3(0.0, 0.0, 0.0);
|
||||
float3 gradCos = float3(0.0, 0.0, 0.0);
|
||||
float3 gradSin = float3(0.0, 0.0, 0.0);
|
||||
|
||||
for (int dz = -1; dz <= 2; dz++)
|
||||
{
|
||||
float gz = max(0.0, abs(local.z - (float)dz) - 0.5);
|
||||
for (int dy = -1; dy <= 2; dy++)
|
||||
{
|
||||
float gy = max(0.0, abs(local.y - (float)dy) - 0.5);
|
||||
for (int dx = -1; dx <= 2; dx++)
|
||||
{
|
||||
float gx = max(0.0, abs(local.x - (float)dx) - 0.5);
|
||||
if (gx * gx + gy * gy + gz * gz >= 2.25)
|
||||
continue;
|
||||
|
||||
float3 gridStep = float3(dx, dy, dz);
|
||||
int3 hash = cultmath_pcg3d(int3(cell + gridStep));
|
||||
float3 jitter = float3(
|
||||
((uint)hash.x >> 8) * (1.0 / 16777216.0) - 0.5,
|
||||
((uint)hash.y >> 8) * (1.0 / 16777216.0) - 0.5,
|
||||
((uint)hash.z >> 8) * (1.0 / 16777216.0) - 0.5);
|
||||
float3 v = local - gridStep - jitter;
|
||||
float falloff = exp(-2.0 * dot(v, v));
|
||||
float w = max(0.0, falloff - 0.01111);
|
||||
float3 dw = w > 0.0 ? (-4.0 * falloff) * v : float3(0.0, 0.0, 0.0);
|
||||
float phase = dot(v, side) + phaseOffset;
|
||||
float c = cos(phase);
|
||||
float s = sin(phase);
|
||||
|
||||
sumW += w;
|
||||
sumCos += w * c;
|
||||
sumSin += w * s;
|
||||
gradW += dw;
|
||||
gradCos += dw * c - (w * s) * side;
|
||||
gradSin += dw * s + (w * c) * side;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float invW = 1.0 / sumW;
|
||||
float rawCos = sumCos * invW;
|
||||
float rawSin = sumSin * invW;
|
||||
float3 gradRawCos = (gradCos - rawCos * gradW) * invW;
|
||||
float3 gradRawSin = (gradSin - rawSin * gradW) * invW;
|
||||
float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin);
|
||||
float floorLength = 1.0 - normalization;
|
||||
|
||||
CultPhasor result;
|
||||
if (rawLength > floorLength)
|
||||
{
|
||||
float outCos = rawCos / rawLength;
|
||||
float outSin = rawSin / rawLength;
|
||||
float3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
|
||||
result.cos = float4((gradRawCos - outCos * gradLength) / rawLength, outCos);
|
||||
result.sin = float4((gradRawSin - outSin * gradLength) / rawLength, outSin);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.cos = float4(gradRawCos / floorLength, rawCos / floorLength);
|
||||
result.sin = float4(gradRawSin / floorLength, rawSin / floorLength);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endif
|
||||
7
packages/cultmath/shaders/CultMath.Phacelle.hlsl.meta
Normal file
7
packages/cultmath/shaders/CultMath.Phacelle.hlsl.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e07a3b95c1d84f2aa6b83d4c5f19a0d7
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -503,6 +503,8 @@ CultCellular cultmath_cellular(float3 p)
|
|||
return result;
|
||||
}
|
||||
|
||||
#include "CultMath.Phacelle.hlsl"
|
||||
|
||||
float cultmath_value_noise(float2 position)
|
||||
{
|
||||
float2 cell = floor(position);
|
||||
|
|
|
|||
|
|
@ -8,6 +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>
|
||||
<Version>0.2.4</Version>
|
||||
<Description>Clean-room C# math shaped for HLSL-style renderer code.</Description>
|
||||
<Authors>GameCult</Authors>
|
||||
|
|
@ -21,6 +23,8 @@
|
|||
<ItemGroup>
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
|
||||
<None Include="..\..\shaders\CultMath.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.hlsl" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\shaders\CultMath.Phacelle.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.Phacelle.hlsl" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\THIRD-PARTY-NOTICES.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
27
packages/cultmath/src/CultMath/CultPhasor.cs
Normal file
27
packages/cultmath/src/CultMath/CultPhasor.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|
||||
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// The result type of math.phacelle (math.Phacelle.cs), Rune Skovbo Johansen's Phacelle noise
|
||||
// (Copyright (c) 2025, MPL-2.0; see THIRD-PARTY-NOTICES.md).
|
||||
|
||||
namespace CultMath;
|
||||
|
||||
/// <summary>
|
||||
/// The value-and-gradient result of <see cref="math.phacelle(float3, float3, float, float)"/>
|
||||
/// (invariant 8's struct return shape). Both fields use the <c>float4(gradient.xyz, value.w)</c>
|
||||
/// layout of a bare value-and-gradient return.
|
||||
/// </summary>
|
||||
public struct CultPhasor
|
||||
{
|
||||
/// <summary>(∇cos, cos): the normalized cosine wave and its gradient.</summary>
|
||||
public float4 cos;
|
||||
|
||||
/// <summary>(∇sin, sin): the normalized sine wave and its gradient.</summary>
|
||||
public float4 sin;
|
||||
|
||||
public CultPhasor(float4 cos, float4 sin)
|
||||
{
|
||||
this.cos = cos;
|
||||
this.sin = sin;
|
||||
}
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/CultPhasor.cs.meta
Normal file
2
packages/cultmath/src/CultMath/CultPhasor.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 807ae2c8a758cf31896da12f1e5047bd
|
||||
127
packages/cultmath/src/CultMath/math.Phacelle.cs
Normal file
127
packages/cultmath/src/CultMath/math.Phacelle.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|
||||
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the
|
||||
// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke
|
||||
// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact
|
||||
// gradient and exact pruning. This file and CultPhasor.cs are the MPL-2.0 files of CultMath; the
|
||||
// rest of the package is MIT (see THIRD-PARTY-NOTICES.md).
|
||||
|
||||
namespace CultMath;
|
||||
|
||||
public static partial class math
|
||||
{
|
||||
// Johansen's Phacelle noise (phase + cell), generalised to 3D cells with a caller-supplied
|
||||
// stripe wave vector. Each of the 4x4x4 cells around p carries one jittered feature point
|
||||
// (jitter in [-0.5, 0.5) per axis, from pcg3d over the integer cell coordinate, the same
|
||||
// integer-hashing rule as cellular) and one cosine/sine wave that runs along `side` (the wave
|
||||
// vector: its length is 2*pi times the stripe frequency, and the caller chooses which
|
||||
// perpendicular it is; on a unit sphere that is cross(p_hat, flow)). The waves are blended with
|
||||
// the bell weight max(0, exp(-2 d^2) - 0.01111) (exactly 0 at distance 1.5, the nearest a
|
||||
// feature outside the 4x4x4 block can be in 2D, which is what keeps cell-grid lines out), and
|
||||
// the blended phasor is normalized: its length is stretched to 1 wherever the raw length is at
|
||||
// least 1 - normalization, and scaled by 1 / (1 - normalization) below that. `offset` is the
|
||||
// phase in cycles (1 is a full turn). Value and normalization follow Johansen's
|
||||
// PhacelleNoise as ported in lpmitchell/AdvancedTerrainErosion.
|
||||
//
|
||||
// `normalization` is meant to lie in [0, 1]. At 0 the output is the raw blend (a phasor of
|
||||
// length at most 1); at 1 the floor is 0 and every output is stretched to length 1. Values of
|
||||
// 1 or more saturate at that (the floor is never above the raw length), and a negative value
|
||||
// scales every output down by 1 / (1 - normalization) < 1. The normalized gradient scales as
|
||||
// 1 / |I| where I is the blended phasor (below), so it grows wherever the blend nearly
|
||||
// cancels; a normalization near 1 lets that happen at every point.
|
||||
//
|
||||
// The gradient is exact: it carries the weight derivative (grad w = -4 v exp(-2 |v|^2) where
|
||||
// w > 0) and the normalization, not upstream's convention grad(cos) ~ -sin * side, which
|
||||
// ignores the weight gradient (Asura probe: 16.8 deg off at the median, 97.8 at the 95th
|
||||
// percentile; the exact form is 0.00 and 0.03). With P = sum(w * (cos, sin)) and W = sum(w),
|
||||
// I = P / W has grad I = (grad P - I * grad W) / W; above the threshold the output is I / |I|
|
||||
// with gradient (grad I - out * grad|I|) / |I|, below it grad I / (1 - normalization). The
|
||||
// value is continuous at both seams below; the gradient is not.
|
||||
// * Where |I| crosses the threshold, the gradient jumps between the two branches.
|
||||
// * Where a cell's weight leaves its support (d^2 = ln(1 / 0.01111) / 2 = 2.24995, distance
|
||||
// 1.49998), grad w jumps from -4 * 0.01111 * v to 0: a per-cell jump of length
|
||||
// 0.0444 * |v|, 0.0667 at |v| = 1.5. Measured over random points, the resulting jump in
|
||||
// the output gradient is a median 1.5% of its length and at most about 27%.
|
||||
//
|
||||
// W is the sum of 64 weights and is positive for every input pcg3d produces (the smallest
|
||||
// W found by hill-climbing the hash-driven cell contents is 0.056). It is 0, and the output
|
||||
// NaN, only if all 64 weights vanish, which takes adversarial jitter no hash yields.
|
||||
//
|
||||
// Pruning is exact: per axis, |local - g| - 0.5 (floored at 0) lower-bounds |v| for any jitter,
|
||||
// so a cell whose summed square is at least 2.25 lies outside the support (2.24995 < 2.25) and
|
||||
// has weight exactly 0: it contributes nothing to any sum. Cells whose bound sits just under
|
||||
// 2.25 can still have a nonzero weight, so the cut cannot be lowered. On average the prune
|
||||
// keeps about 45 of the 64 cells (a saving of about 30%); only about 14 have a nonzero weight.
|
||||
// Only the internal overload can switch the prune off; PhacelleTests uses it to pin bit equality.
|
||||
public static CultPhasor phacelle(float3 p, float3 side, float offset, float normalization) =>
|
||||
phacelle(p, side, offset, normalization, true);
|
||||
|
||||
internal static CultPhasor phacelle(float3 p, float3 side, float offset, float normalization, bool prune)
|
||||
{
|
||||
var cell = floor(p);
|
||||
var local = p - cell;
|
||||
var phaseOffset = offset * TAU;
|
||||
var sumW = 0.0f;
|
||||
var sumCos = 0.0f;
|
||||
var sumSin = 0.0f;
|
||||
var gradW = new float3(0.0f, 0.0f, 0.0f);
|
||||
var gradCos = new float3(0.0f, 0.0f, 0.0f);
|
||||
var gradSin = new float3(0.0f, 0.0f, 0.0f);
|
||||
|
||||
for (var dz = -1; dz <= 2; dz++)
|
||||
{
|
||||
var gz = max(0.0f, abs(local.z - (float)dz) - 0.5f);
|
||||
for (var dy = -1; dy <= 2; dy++)
|
||||
{
|
||||
var gy = max(0.0f, abs(local.y - (float)dy) - 0.5f);
|
||||
for (var dx = -1; dx <= 2; dx++)
|
||||
{
|
||||
var gx = max(0.0f, abs(local.x - (float)dx) - 0.5f);
|
||||
if (prune && gx * gx + gy * gy + gz * gz >= 2.25f)
|
||||
continue;
|
||||
|
||||
var gridStep = new float3(dx, dy, dz);
|
||||
var hash = pcg3d(int3(cell + gridStep));
|
||||
var jitter = new float3(cellular_unit(hash.x) - 0.5f, cellular_unit(hash.y) - 0.5f, cellular_unit(hash.z) - 0.5f);
|
||||
var v = local - gridStep - jitter;
|
||||
var falloff = exp(-2.0f * dot(v, v));
|
||||
var w = max(0.0f, falloff - 0.01111f);
|
||||
var dw = w > 0.0f ? (-4.0f * falloff) * v : new float3(0.0f, 0.0f, 0.0f);
|
||||
var phase = dot(v, side) + phaseOffset;
|
||||
var c = cos(phase);
|
||||
var s = sin(phase);
|
||||
|
||||
sumW += w;
|
||||
sumCos += w * c;
|
||||
sumSin += w * s;
|
||||
gradW += dw;
|
||||
gradCos += dw * c - (w * s) * side;
|
||||
gradSin += dw * s + (w * c) * side;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var invW = 1.0f / sumW;
|
||||
var rawCos = sumCos * invW;
|
||||
var rawSin = sumSin * invW;
|
||||
var gradRawCos = (gradCos - rawCos * gradW) * invW;
|
||||
var gradRawSin = (gradSin - rawSin * gradW) * invW;
|
||||
var rawLength = sqrt(rawCos * rawCos + rawSin * rawSin);
|
||||
var floorLength = 1.0f - normalization;
|
||||
|
||||
if (rawLength > floorLength)
|
||||
{
|
||||
var outCos = rawCos / rawLength;
|
||||
var outSin = rawSin / rawLength;
|
||||
var gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
|
||||
return new CultPhasor(
|
||||
new float4((gradRawCos - outCos * gradLength) / rawLength, outCos),
|
||||
new float4((gradRawSin - outSin * gradLength) / rawLength, outSin));
|
||||
}
|
||||
|
||||
return new CultPhasor(
|
||||
new float4(gradRawCos / floorLength, rawCos / floorLength),
|
||||
new float4(gradRawSin / floorLength, rawSin / floorLength));
|
||||
}
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/math.Phacelle.cs.meta
Normal file
2
packages/cultmath/src/CultMath/math.Phacelle.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2c9d4e6b1a8f47d3b05e7a3c9d1f8e26
|
||||
|
|
@ -7,7 +7,7 @@ public sealed class HlslMirrorTests
|
|||
[Fact]
|
||||
public void HlslMirrorDoesNotPublishGeometryKernels()
|
||||
{
|
||||
var source = File.ReadAllText(Path.Combine(GetCultMathRoot(), "shaders", "CultMath.hlsl"));
|
||||
var source = HlslSourceCompatibilityTests.ReadShaderSource(GetCultMathRoot());
|
||||
Assert.DoesNotContain("AdvancedErosionFilter.hlsl", source);
|
||||
Assert.DoesNotContain("Planetary.hlsl", source);
|
||||
Assert.DoesNotContain("spherical_erosion", source);
|
||||
|
|
@ -17,7 +17,7 @@ public sealed class HlslMirrorTests
|
|||
[Fact]
|
||||
public void HlslMirrorPublishesCultMathPrimitives()
|
||||
{
|
||||
var include = File.ReadAllText(Path.Combine(GetCultMathRoot(), "shaders", "CultMath.hlsl"));
|
||||
var include = HlslSourceCompatibilityTests.ReadShaderSource(GetCultMathRoot());
|
||||
var requiredSymbols = new[]
|
||||
{
|
||||
"cultmath_radians",
|
||||
|
|
@ -62,6 +62,17 @@ public sealed class HlslMirrorTests
|
|||
Assert.DoesNotContain("cultmath_normalize", include);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CultMath.hlsl")]
|
||||
[InlineData("CultMath.Phacelle.hlsl")]
|
||||
public void UnityPackageShaderIsIdenticalToTheCanonicalShader(string name)
|
||||
{
|
||||
var root = GetCultMathRoot();
|
||||
Assert.Equal(
|
||||
File.ReadAllText(Path.Combine(root, "shaders", name)).ReplaceLineEndings("\n"),
|
||||
File.ReadAllText(Path.Combine(root, "unity", "org.gamecult.cultmath", "Shaders", name)).ReplaceLineEndings("\n"));
|
||||
}
|
||||
|
||||
private static string GetCultMathRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public sealed class HlslSourceCompatibilityTests
|
|||
Assert.NotNull(ShaderFunction(shader, "cultmath_snoise_grad", typeof(float3)));
|
||||
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)));
|
||||
}
|
||||
|
||||
// HLSL functions carry no access modifier, so they compile as private instance methods.
|
||||
|
|
@ -130,7 +131,63 @@ public sealed class HlslSourceCompatibilityTests
|
|||
}
|
||||
|
||||
Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}");
|
||||
Assert.Equal(33, compared.Count);
|
||||
Assert.Equal(34, compared.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The generic comparison above passes about a hundred inputs through <c>cultmath_phacelle</c>, and
|
||||
/// its prune only changes an output for the rare cell whose weight is nonzero but whose lower
|
||||
/// bound sits just under the 2.25 cut (Soul's F6 lesson for cellular): a mirror whose cut is 2.0
|
||||
/// survives a hundred inputs. A dense sweep of realistic arguments closes that gap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PhacelleMirrorMatchesCSharpBitForBitAcrossADenseSweep()
|
||||
{
|
||||
var (assembly, errors) = CompileShaderMirror();
|
||||
Assert.True(assembly is not null, string.Join(Environment.NewLine, errors));
|
||||
var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!;
|
||||
var shader = Activator.CreateInstance(shaderType);
|
||||
var mirror = ShaderFunction(shaderType, "cultmath_phacelle", typeof(float3), typeof(float3), typeof(float), typeof(float))!;
|
||||
|
||||
var random = new System.Random(0x5EED2);
|
||||
float Next(float extent) => (random.NextSingle() * 2.0f - 1.0f) * extent;
|
||||
for (var i = 0; i < 20000; i++)
|
||||
{
|
||||
var p = new float3(Next(50.0f), Next(50.0f), Next(50.0f));
|
||||
var side = new float3(Next(9.0f), Next(9.0f), Next(9.0f));
|
||||
var offset = random.NextSingle();
|
||||
var normalization = random.NextSingle();
|
||||
var expected = Values(phacelle(p, side, offset, normalization));
|
||||
var actual = Values(mirror.Invoke(shader, new object[] { p, side, offset, normalization })!);
|
||||
Assert.True(BitwiseEqual(expected, actual), $"mirror differs from C# at p = {p}, side = {side}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dense sweep almost never meets a cell whose box bound is just under the 2.25 cut and whose
|
||||
/// weight is still nonzero: a mirror cut of 2.245, 2.24 or 2.2 survives it. These points sit next
|
||||
/// to such cells (<see cref="PhacelleTests.PruneCutPoints"/>), so the mirror must match C# bit for
|
||||
/// bit, and must match the unpruned C# sum (the shader has no prune switch, so that is the
|
||||
/// independent statement that the cut is exact).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PhacelleMirrorMatchesCSharpBitForBitNextToCellsJustInsideTheCut()
|
||||
{
|
||||
var (assembly, errors) = CompileShaderMirror();
|
||||
Assert.True(assembly is not null, string.Join(Environment.NewLine, errors));
|
||||
var shaderType = assembly!.GetType("CultMathHlsl.HlslShader")!;
|
||||
var shader = Activator.CreateInstance(shaderType);
|
||||
var mirror = ShaderFunction(shaderType, "cultmath_phacelle", typeof(float3), typeof(float3), typeof(float), typeof(float))!;
|
||||
|
||||
foreach (var p in PhacelleTests.PruneCutPoints)
|
||||
{
|
||||
Assert.True(PhacelleTests.HasCellJustInsideTheCut(p), $"no cell just inside the cut at {p}");
|
||||
var actual = Values(mirror.Invoke(shader, new object[] { p, PhacelleTests.PruneCutSide, PhacelleTests.PruneCutOffset, PhacelleTests.PruneCutNormalization })!);
|
||||
var pruned = Values(phacelle(p, PhacelleTests.PruneCutSide, PhacelleTests.PruneCutOffset, PhacelleTests.PruneCutNormalization));
|
||||
var unpruned = Values(phacelle(p, PhacelleTests.PruneCutSide, PhacelleTests.PruneCutOffset, PhacelleTests.PruneCutNormalization, false));
|
||||
Assert.True(BitwiseEqual(pruned, actual), $"mirror differs from C# at p = {p}");
|
||||
Assert.True(BitwiseEqual(unpruned, actual), $"mirror differs from the unpruned C# sum at p = {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -253,6 +310,18 @@ public sealed class HlslSourceCompatibilityTests
|
|||
? (float.IsNaN(a) && float.IsNaN(b)) || BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b)
|
||||
: p.First.Value.Equals(p.Second.Value)));
|
||||
|
||||
/// <summary>
|
||||
/// The text of <c>CultMath.hlsl</c> with each <c>#include "name"</c> replaced in place by the
|
||||
/// included file from the same directory, as the shader compiler would resolve it. Only the
|
||||
/// package's own includes exist, so a missing file is an error rather than a skip.
|
||||
/// </summary>
|
||||
internal static string ReadShaderSource(string cultMathRoot)
|
||||
{
|
||||
var shaders = Path.Combine(cultMathRoot, "shaders");
|
||||
return Regex.Replace(File.ReadAllText(Path.Combine(shaders, "CultMath.hlsl")), @"(?m)^#include ""([^""]+)""[ \t]*\r?$",
|
||||
match => File.ReadAllText(Path.Combine(shaders, match.Groups[1].Value)));
|
||||
}
|
||||
|
||||
/// <summary>The documented HLSL-to-C# transformations, and nothing else.</summary>
|
||||
internal static string TransformHlslToCSharp(string hlsl)
|
||||
{
|
||||
|
|
@ -281,7 +350,7 @@ public sealed class HlslSourceCompatibilityTests
|
|||
|
||||
private static (Assembly? Assembly, IReadOnlyList<string> Errors) CompileShaderMirror()
|
||||
{
|
||||
var hlsl = File.ReadAllText(Path.Combine(FindCultMathRoot(), "shaders", "CultMath.hlsl"));
|
||||
var hlsl = ReadShaderSource(FindCultMathRoot());
|
||||
var tree = CSharpSyntaxTree.ParseText(TransformHlslToCSharp(hlsl));
|
||||
var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
|
||||
.Split(Path.PathSeparator)
|
||||
|
|
|
|||
340
packages/cultmath/tests/CultMath.Tests/PhacelleTests.cs
Normal file
340
packages/cultmath/tests/CultMath.Tests/PhacelleTests.cs
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
using System;
|
||||
using CultMath;
|
||||
using Xunit;
|
||||
|
||||
namespace CultMath.Tests;
|
||||
|
||||
public sealed class PhacelleTests
|
||||
{
|
||||
// Every tolerance below is at least 5x the largest error the same test measures at its seed.
|
||||
private const float GradientEps = 1.0e-3f;
|
||||
private const float GradientTolerance = 5.0e-3f;
|
||||
private const double ValueTolerance = 1.6e-5;
|
||||
private const double ThresholdBand = 0.02;
|
||||
|
||||
private static float3 RandomPoint(System.Random random, float extent = 3.0f) => new(
|
||||
(random.NextSingle() * 2.0f - 1.0f) * extent, (random.NextSingle() * 2.0f - 1.0f) * extent, (random.NextSingle() * 2.0f - 1.0f) * extent);
|
||||
|
||||
// A wave vector of length 2*pi times a stripe frequency in [0.5, 1.5], in a random direction.
|
||||
private static float3 RandomSide(System.Random random)
|
||||
{
|
||||
float3 direction;
|
||||
do
|
||||
{
|
||||
direction = new float3(random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f);
|
||||
}
|
||||
while (math.length(direction) < 0.1f);
|
||||
|
||||
return math.normalize(direction) * (math.TAU * (0.5f + random.NextSingle()));
|
||||
}
|
||||
|
||||
// ---- An independent double-precision evaluation: all 64 cells, no prune, no gradient. ----
|
||||
//
|
||||
// It shares only the hash (pcg3d over the integer cell coordinate, top 24 bits) and the constants
|
||||
// the function is specified by: weight max(0, exp(-2 d^2) - 0.01111), jitter in [-0.5, 0.5).
|
||||
|
||||
private readonly record struct Reference(double Cos, double Sin, double RawLength, double SupportEdgeGap);
|
||||
|
||||
private static Reference Evaluate(float3 p, float3 side, float offset, float normalization)
|
||||
{
|
||||
double fx = Math.Floor(p.x), fy = Math.Floor(p.y), fz = Math.Floor(p.z);
|
||||
double lx = p.x - fx, ly = p.y - fy, lz = p.z - fz;
|
||||
double sumW = 0.0, sumCos = 0.0, sumSin = 0.0, edgeGap = double.MaxValue;
|
||||
var supportRadius = Math.Sqrt(Math.Log(1.0 / 0.01111) / 2.0);
|
||||
for (var dz = -1; dz <= 2; dz++)
|
||||
for (var dy = -1; dy <= 2; dy++)
|
||||
for (var dx = -1; dx <= 2; dx++)
|
||||
{
|
||||
var hash = math.pcg3d(new int3((int)fx + dx, (int)fy + dy, (int)fz + dz));
|
||||
double vx = lx - dx - (math.cellular_unit(hash.x) - 0.5);
|
||||
double vy = ly - dy - (math.cellular_unit(hash.y) - 0.5);
|
||||
double vz = lz - dz - (math.cellular_unit(hash.z) - 0.5);
|
||||
var d2 = vx * vx + vy * vy + vz * vz;
|
||||
edgeGap = Math.Min(edgeGap, Math.Abs(Math.Sqrt(d2) - supportRadius));
|
||||
var w = Math.Max(0.0, Math.Exp(-2.0 * d2) - 0.01111);
|
||||
var phase = vx * side.x + vy * side.y + vz * side.z + (double)offset * math.TAU;
|
||||
sumW += w;
|
||||
sumCos += w * Math.Cos(phase);
|
||||
sumSin += w * Math.Sin(phase);
|
||||
}
|
||||
|
||||
double rawCos = sumCos / sumW, rawSin = sumSin / sumW;
|
||||
var raw = Math.Sqrt(rawCos * rawCos + rawSin * rawSin);
|
||||
var length = Math.Max(1.0 - normalization, raw);
|
||||
return new Reference(rawCos / length, rawSin / length, raw, edgeGap);
|
||||
}
|
||||
|
||||
private static float3 CentralDifference(Func<float3, float> f, float3 p, float eps) => new(
|
||||
(f(p + new float3(eps, 0.0f, 0.0f)) - f(p - new float3(eps, 0.0f, 0.0f))) / (2.0f * eps),
|
||||
(f(p + new float3(0.0f, eps, 0.0f)) - f(p - new float3(0.0f, eps, 0.0f))) / (2.0f * eps),
|
||||
(f(p + new float3(0.0f, 0.0f, eps)) - f(p - new float3(0.0f, 0.0f, eps))) / (2.0f * eps));
|
||||
|
||||
private static float MaxComponentError(float3 numeric, float4 analytic) => MathF.Max(
|
||||
MathF.Abs(numeric.x - analytic.x), MathF.Max(MathF.Abs(numeric.y - analytic.y), MathF.Abs(numeric.z - analytic.z)));
|
||||
|
||||
// ---- Value: the function is Johansen's, on 3D cells ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0f)]
|
||||
[InlineData(0.5f)]
|
||||
[InlineData(0.9f)]
|
||||
[InlineData(1.0f)]
|
||||
public void ValueMatchesTheIndependentReferenceAndNormalizesAboveTheThreshold(float normalization)
|
||||
{
|
||||
var random = new System.Random(0x7ACE1 + (int)(normalization * 10.0f));
|
||||
var maxError = 0.0;
|
||||
var above = 0;
|
||||
var below = 0;
|
||||
for (var i = 0; i < 3000; i++)
|
||||
{
|
||||
var p = RandomPoint(random, 40.0f);
|
||||
var side = RandomSide(random);
|
||||
var offset = random.NextSingle();
|
||||
var actual = math.phacelle(p, side, offset, normalization);
|
||||
var expected = Evaluate(p, side, offset, normalization);
|
||||
|
||||
maxError = Math.Max(maxError, Math.Max(Math.Abs(actual.cos.w - expected.Cos), Math.Abs(actual.sin.w - expected.Sin)));
|
||||
if (expected.RawLength >= 1.0 - normalization + ThresholdBand)
|
||||
{
|
||||
// The output magnitude is 1 wherever the raw magnitude reaches 1 - normalization.
|
||||
above++;
|
||||
var magnitude = Math.Sqrt((double)actual.cos.w * actual.cos.w + (double)actual.sin.w * actual.sin.w);
|
||||
Assert.True(Math.Abs(magnitude - 1.0) < ValueTolerance, $"magnitude {magnitude} above the threshold at {p}");
|
||||
}
|
||||
else if (expected.RawLength <= 1.0 - normalization - ThresholdBand)
|
||||
{
|
||||
below++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(maxError < ValueTolerance, $"max value error against the reference {maxError}");
|
||||
if (normalization > 0.0f)
|
||||
Assert.True(above >= 500, $"only {above} points above the threshold");
|
||||
if (normalization is > 0.0f and < 1.0f)
|
||||
Assert.True(below >= 50, $"only {below} points below the threshold");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValueIsPeriodicInOffsetAndAQuarterCycleRotatesCosIntoSin()
|
||||
{
|
||||
// offset is in cycles: a quarter turn maps cos to -sin and sin to cos, and a whole turn is the
|
||||
// identity. The weights and the normalization do not depend on the phase, so this is exact
|
||||
// up to float rounding.
|
||||
var random = new System.Random(0x7ACE2);
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
var p = RandomPoint(random);
|
||||
var side = RandomSide(random);
|
||||
var offset = random.NextSingle();
|
||||
var basePhasor = math.phacelle(p, side, offset, 0.5f);
|
||||
var quarter = math.phacelle(p, side, offset + 0.25f, 0.5f);
|
||||
var whole = math.phacelle(p, side, offset + 1.0f, 0.5f);
|
||||
|
||||
Assert.True(MathF.Abs(quarter.cos.w + basePhasor.sin.w) < 2.0e-5f);
|
||||
Assert.True(MathF.Abs(quarter.sin.w - basePhasor.cos.w) < 2.0e-5f);
|
||||
Assert.True(MathF.Abs(whole.cos.w - basePhasor.cos.w) < 2.0e-5f);
|
||||
Assert.True(MathF.Abs(whole.sin.w - basePhasor.sin.w) < 2.0e-5f);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Invariant 8: the gradient is exact ----
|
||||
|
||||
[Fact]
|
||||
public void GradientOfCosAndSinMatchesCentralDifferencesOnBothSidesOfTheNormalizationThreshold()
|
||||
{
|
||||
// Above the threshold the output is I / |I|; below it, I / (1 - normalization). The gradient
|
||||
// is discontinuous where |I| crosses the threshold, and at the edge of each cell's weight support
|
||||
// (distance 1.49998, where max(0, ...) starts: a jump of 4 d 0.01111 in grad w), so a band
|
||||
// around each is excluded (by the
|
||||
// reference's raw length, since the output alone cannot say how close it is).
|
||||
const float normalization = 0.5f;
|
||||
var random = new System.Random(0x7ACE3);
|
||||
var above = 0;
|
||||
var below = 0;
|
||||
var maxError = 0.0f;
|
||||
for (var attempts = 0; attempts < 20000 && (above < 150 || below < 150); attempts++)
|
||||
{
|
||||
var p = RandomPoint(random);
|
||||
var side = RandomSide(random);
|
||||
var offset = random.NextSingle();
|
||||
var reference = Evaluate(p, side, offset, normalization);
|
||||
var distance = reference.RawLength - (1.0 - normalization);
|
||||
if (Math.Abs(distance) < ThresholdBand || reference.SupportEdgeGap < 3.0 * GradientEps)
|
||||
continue;
|
||||
if (distance > 0.0 ? above >= 150 : below >= 150)
|
||||
continue;
|
||||
if (distance > 0.0) above++; else below++;
|
||||
|
||||
var analytic = math.phacelle(p, side, offset, normalization);
|
||||
var numericCos = CentralDifference(q => math.phacelle(q, side, offset, normalization).cos.w, p, GradientEps);
|
||||
var numericSin = CentralDifference(q => math.phacelle(q, side, offset, normalization).sin.w, p, GradientEps);
|
||||
maxError = MathF.Max(maxError, MathF.Max(MaxComponentError(numericCos, analytic.cos), MaxComponentError(numericSin, analytic.sin)));
|
||||
}
|
||||
|
||||
Assert.True(above >= 150 && below >= 150, $"{above} points above and {below} below the threshold");
|
||||
Assert.True(maxError < GradientTolerance, $"max gradient error against central differences {maxError}");
|
||||
}
|
||||
|
||||
// ---- Stripes run along the flow ----
|
||||
|
||||
[Fact]
|
||||
public void OnTheUnitSphereStripesRunAlongTheFlow()
|
||||
{
|
||||
// side = cross(p_hat, flow) * 2*pi is tangent to the sphere and perpendicular to the flow, so the
|
||||
// cosine should change across the stripes (along side) and hardly at all along them (along
|
||||
// flow). Asura's probe measured 0.212 for this ratio, against 0.202 for the planar upstream.
|
||||
const float radiusInCells = 10.0f;
|
||||
var random = new System.Random(0x7ACE4);
|
||||
var reference = math.normalize(new float3(0.3f, 0.5f, 0.8f));
|
||||
var alongFlow = 0.0;
|
||||
var acrossFlow = 0.0;
|
||||
var used = 0;
|
||||
while (used < 2000)
|
||||
{
|
||||
var hat = math.normalize(new float3(random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f));
|
||||
var tangent = reference - hat * math.dot(hat, reference);
|
||||
if (math.length(tangent) < 0.2f)
|
||||
continue;
|
||||
|
||||
var flow = math.normalize(tangent);
|
||||
var sideDirection = math.cross(hat, flow);
|
||||
var phasor = math.phacelle(hat * radiusInCells, sideDirection * math.TAU, 0.25f, 0.5f);
|
||||
var gradient = new float3(phasor.cos.x, phasor.cos.y, phasor.cos.z);
|
||||
alongFlow += Math.Abs(math.dot(gradient, flow));
|
||||
acrossFlow += Math.Abs(math.dot(gradient, sideDirection));
|
||||
used++;
|
||||
}
|
||||
|
||||
var ratio = alongFlow / acrossFlow;
|
||||
Assert.True(ratio <= 0.25, $"mean|d cos/d flow| / mean|d cos/d side| = {ratio}");
|
||||
}
|
||||
|
||||
// ---- Continuity ----
|
||||
|
||||
[Fact]
|
||||
public void ValueStepDifferenceShrinksTenfoldPerTenfoldStepIncludingAcrossCellPlanes()
|
||||
{
|
||||
// Pairs straddle an integer plane on each axis (where the 4x4x4 window moves by one cell and a
|
||||
// weight that did not vanish at the window edge would jump) and also start at random points.
|
||||
// Lipschitz continuity means the largest difference over a step scales with the step.
|
||||
var random = new System.Random(0x7ACE5);
|
||||
var steps = new[] { 1.0e-3f, 1.0e-4f, 1.0e-5f };
|
||||
var maxDifference = new float[steps.Length];
|
||||
for (var i = 0; i < 3000; i++)
|
||||
{
|
||||
var p = RandomPoint(random);
|
||||
var side = RandomSide(random);
|
||||
var offset = random.NextSingle();
|
||||
var axis = i % 3;
|
||||
if (i % 2 == 0)
|
||||
p = SetAxis(p, axis, MathF.Round(GetAxis(p, axis)));
|
||||
for (var k = 0; k < steps.Length; k++)
|
||||
{
|
||||
var low = math.phacelle(SetAxis(p, axis, GetAxis(p, axis) - steps[k] * 0.5f), side, offset, 0.5f);
|
||||
var high = math.phacelle(SetAxis(p, axis, GetAxis(p, axis) + steps[k] * 0.5f), side, offset, 0.5f);
|
||||
maxDifference[k] = MathF.Max(maxDifference[k], MathF.Max(MathF.Abs(low.cos.w - high.cos.w), MathF.Abs(low.sin.w - high.sin.w)));
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(maxDifference[1] * 8.0f <= maxDifference[0], $"1e-3: {maxDifference[0]}, 1e-4: {maxDifference[1]}");
|
||||
Assert.True(maxDifference[2] * 8.0f <= maxDifference[1], $"1e-4: {maxDifference[1]}, 1e-5: {maxDifference[2]}");
|
||||
}
|
||||
|
||||
private static float GetAxis(float3 v, int axis) => axis == 0 ? v.x : axis == 1 ? v.y : v.z;
|
||||
|
||||
private static float3 SetAxis(float3 v, int axis, float value) =>
|
||||
axis == 0 ? new float3(value, v.y, v.z) : axis == 1 ? new float3(v.x, value, v.z) : new float3(v.x, v.y, value);
|
||||
|
||||
// ---- Exact pruning ----
|
||||
|
||||
[Fact]
|
||||
public void PrunedOutputIsBitEqualToTheUnprunedSum()
|
||||
{
|
||||
var random = new System.Random(0x7ACE6);
|
||||
for (var i = 0; i < 20000; i++)
|
||||
{
|
||||
var p = RandomPoint(random, 50.0f);
|
||||
var side = RandomSide(random);
|
||||
var offset = random.NextSingle();
|
||||
var normalization = random.NextSingle();
|
||||
var pruned = math.phacelle(p, side, offset, normalization);
|
||||
var unpruned = math.phacelle(p, side, offset, normalization, false);
|
||||
|
||||
AssertBitEqual(unpruned.cos, pruned.cos);
|
||||
AssertBitEqual(unpruned.sin, pruned.sin);
|
||||
}
|
||||
}
|
||||
|
||||
// Points next to a cell whose jitter sits at the far corner of its cube: the cell's box bound is
|
||||
// just under the 2.25 cut (2.2451 to 2.25) while its real distance is still under the support
|
||||
// radius (d^2 < 2.24995), so its weight is small but nonzero and a prune that cuts even slightly
|
||||
// low (2.245, 2.24, 2.2) drops a contribution and changes output bits. Random points meet such
|
||||
// a cell about once in 10^4 evaluations, which is why the random sweep above let those cuts
|
||||
// through. Each was found once by an offline search (cells K in [-12, 12]^3 whose jitter on one
|
||||
// axis lies within 0.0008 of the cube face; p is the feature moved 1.4996 out along that axis,
|
||||
// so |p - feature| = 1.4996 and the bound is about 1.4988). The witness check below re-derives
|
||||
// that property from the hash, so a change to pcg3d fails loudly instead of degenerating.
|
||||
// Shared with the HLSL mirror test.
|
||||
internal static readonly float3[] PruneCutPoints =
|
||||
{
|
||||
new(1.77850008f, -2.16020727f, -0.9991166f),
|
||||
new(1.09346282f, -2.37882328f, -3.00082445f),
|
||||
new(-6.39144945f, -3.99953747f, -0.610023975f),
|
||||
new(-0.99940908f, 4.16302729f, 4.07277536f),
|
||||
new(3.99912024f, 7.21890545f, 1.09053993f),
|
||||
new(5.43959713f, 1.99937367f, -6.14929914f),
|
||||
};
|
||||
|
||||
internal static readonly float3 PruneCutSide = new(2.1f, -3.7f, 1.3f);
|
||||
internal const float PruneCutOffset = 0.3f;
|
||||
internal const float PruneCutNormalization = 0.5f;
|
||||
|
||||
// True when some cell of the 4x4x4 window has a box bound in [2.2451, 2.25) and a positive weight.
|
||||
internal static bool HasCellJustInsideTheCut(float3 p)
|
||||
{
|
||||
double fx = Math.Floor(p.x), fy = Math.Floor(p.y), fz = Math.Floor(p.z);
|
||||
double lx = p.x - fx, ly = p.y - fy, lz = p.z - fz;
|
||||
for (var dz = -1; dz <= 2; dz++)
|
||||
for (var dy = -1; dy <= 2; dy++)
|
||||
for (var dx = -1; dx <= 2; dx++)
|
||||
{
|
||||
var gx = Math.Max(0.0, Math.Abs(lx - dx) - 0.5);
|
||||
var gy = Math.Max(0.0, Math.Abs(ly - dy) - 0.5);
|
||||
var gz = Math.Max(0.0, Math.Abs(lz - dz) - 0.5);
|
||||
var bound = gx * gx + gy * gy + gz * gz;
|
||||
if (bound < 2.2451 || bound >= 2.25)
|
||||
continue;
|
||||
|
||||
var hash = math.pcg3d(new int3((int)fx + dx, (int)fy + dy, (int)fz + dz));
|
||||
double vx = lx - dx - (math.cellular_unit(hash.x) - 0.5);
|
||||
double vy = ly - dy - (math.cellular_unit(hash.y) - 0.5);
|
||||
double vz = lz - dz - (math.cellular_unit(hash.z) - 0.5);
|
||||
if (Math.Exp(-2.0 * (vx * vx + vy * vy + vz * vz)) > 0.01111)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrunedOutputIsBitEqualToTheUnprunedSumNextToCellsJustInsideTheCut()
|
||||
{
|
||||
foreach (var p in PruneCutPoints)
|
||||
{
|
||||
Assert.True(HasCellJustInsideTheCut(p), $"no cell just inside the cut at {p}: the fixture no longer exercises the prune");
|
||||
var pruned = math.phacelle(p, PruneCutSide, PruneCutOffset, PruneCutNormalization);
|
||||
var unpruned = math.phacelle(p, PruneCutSide, PruneCutOffset, PruneCutNormalization, false);
|
||||
|
||||
AssertBitEqual(unpruned.cos, pruned.cos);
|
||||
AssertBitEqual(unpruned.sin, pruned.sin);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertBitEqual(float4 expected, float4 actual)
|
||||
{
|
||||
Assert.Equal(BitConverter.SingleToInt32Bits(expected.x), BitConverter.SingleToInt32Bits(actual.x));
|
||||
Assert.Equal(BitConverter.SingleToInt32Bits(expected.y), BitConverter.SingleToInt32Bits(actual.y));
|
||||
Assert.Equal(BitConverter.SingleToInt32Bits(expected.z), BitConverter.SingleToInt32Bits(actual.z));
|
||||
Assert.Equal(BitConverter.SingleToInt32Bits(expected.w), BitConverter.SingleToInt32Bits(actual.w));
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ CultMath is licensed under the Mozilla Public License 2.0 (`MPL-2.0`).
|
|||
|
||||
The complete license text is available from the Mozilla Foundation at
|
||||
<https://www.mozilla.org/MPL/2.0/>. Source and modification history for the
|
||||
precompiled assembly in this package are available in the GameCult/CultMath
|
||||
repository at the corresponding package tag.
|
||||
precompiled assembly in this package are available in the GameCult/CultLib
|
||||
repository (`packages/cultmath`) at the corresponding package tag.
|
||||
|
||||
Third-party code retains its original license. See `THIRD-PARTY-NOTICES.md`.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
This is CultMath's Unity 2021.3-compatible Git-UPM surface. It contains the
|
||||
precompiled `CultMath.dll`, portable symbols, the numeric
|
||||
`Shaders/CultMath.hlsl` mirror, and the `CultMath.UnityBridge` assembly.
|
||||
`Shaders/CultMath.hlsl` mirror (with its `CultMath.Phacelle.hlsl` include), and the `CultMath.UnityBridge` assembly.
|
||||
|
||||
`CultMath.UnityBridge` is the only C# source Unity compiles here. It holds the
|
||||
UnityEngine conversions the engine-free core cannot declare: `ToCultMath()` on
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
#ifndef CULTMATH_PHACELLE_HLSL
|
||||
#define CULTMATH_PHACELLE_HLSL
|
||||
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|
||||
// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the
|
||||
// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke
|
||||
// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact
|
||||
// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after
|
||||
// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md).
|
||||
|
||||
// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit
|
||||
// against CultMath.CultPhasor, same field order.
|
||||
struct CultPhasor
|
||||
{
|
||||
float4 cos;
|
||||
float4 sin;
|
||||
};
|
||||
|
||||
// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave
|
||||
// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors
|
||||
// the C# body operation for operation; the C#-only internal overload that switches the prune off
|
||||
// has no shader twin.
|
||||
CultPhasor cultmath_phacelle(float3 p, float3 side, float offset, float normalization)
|
||||
{
|
||||
float3 cell = floor(p);
|
||||
float3 local = p - cell;
|
||||
float phaseOffset = offset * CULTMATH_TAU;
|
||||
float sumW = 0.0;
|
||||
float sumCos = 0.0;
|
||||
float sumSin = 0.0;
|
||||
float3 gradW = float3(0.0, 0.0, 0.0);
|
||||
float3 gradCos = float3(0.0, 0.0, 0.0);
|
||||
float3 gradSin = float3(0.0, 0.0, 0.0);
|
||||
|
||||
for (int dz = -1; dz <= 2; dz++)
|
||||
{
|
||||
float gz = max(0.0, abs(local.z - (float)dz) - 0.5);
|
||||
for (int dy = -1; dy <= 2; dy++)
|
||||
{
|
||||
float gy = max(0.0, abs(local.y - (float)dy) - 0.5);
|
||||
for (int dx = -1; dx <= 2; dx++)
|
||||
{
|
||||
float gx = max(0.0, abs(local.x - (float)dx) - 0.5);
|
||||
if (gx * gx + gy * gy + gz * gz >= 2.25)
|
||||
continue;
|
||||
|
||||
float3 gridStep = float3(dx, dy, dz);
|
||||
int3 hash = cultmath_pcg3d(int3(cell + gridStep));
|
||||
float3 jitter = float3(
|
||||
((uint)hash.x >> 8) * (1.0 / 16777216.0) - 0.5,
|
||||
((uint)hash.y >> 8) * (1.0 / 16777216.0) - 0.5,
|
||||
((uint)hash.z >> 8) * (1.0 / 16777216.0) - 0.5);
|
||||
float3 v = local - gridStep - jitter;
|
||||
float falloff = exp(-2.0 * dot(v, v));
|
||||
float w = max(0.0, falloff - 0.01111);
|
||||
float3 dw = w > 0.0 ? (-4.0 * falloff) * v : float3(0.0, 0.0, 0.0);
|
||||
float phase = dot(v, side) + phaseOffset;
|
||||
float c = cos(phase);
|
||||
float s = sin(phase);
|
||||
|
||||
sumW += w;
|
||||
sumCos += w * c;
|
||||
sumSin += w * s;
|
||||
gradW += dw;
|
||||
gradCos += dw * c - (w * s) * side;
|
||||
gradSin += dw * s + (w * c) * side;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float invW = 1.0 / sumW;
|
||||
float rawCos = sumCos * invW;
|
||||
float rawSin = sumSin * invW;
|
||||
float3 gradRawCos = (gradCos - rawCos * gradW) * invW;
|
||||
float3 gradRawSin = (gradSin - rawSin * gradW) * invW;
|
||||
float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin);
|
||||
float floorLength = 1.0 - normalization;
|
||||
|
||||
CultPhasor result;
|
||||
if (rawLength > floorLength)
|
||||
{
|
||||
float outCos = rawCos / rawLength;
|
||||
float outSin = rawSin / rawLength;
|
||||
float3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength;
|
||||
result.cos = float4((gradRawCos - outCos * gradLength) / rawLength, outCos);
|
||||
result.sin = float4((gradRawSin - outSin * gradLength) / rawLength, outSin);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.cos = float4(gradRawCos / floorLength, rawCos / floorLength);
|
||||
result.sin = float4(gradRawSin / floorLength, rawSin / floorLength);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5b1c7e0a94d24f6c8a3e2d17f60b9c48
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -503,6 +503,8 @@ CultCellular cultmath_cellular(float3 p)
|
|||
return result;
|
||||
}
|
||||
|
||||
#include "CultMath.Phacelle.hlsl"
|
||||
|
||||
float cultmath_value_noise(float2 position)
|
||||
{
|
||||
float2 cell = floor(position);
|
||||
|
|
|
|||
|
|
@ -19,3 +19,23 @@ permutation and scale as `snoise(float3)`.
|
|||
- Copyright 2011 Ashima Arts.
|
||||
- Upstream: <https://github.com/ashima/webgl-noise>
|
||||
- License: MIT.
|
||||
|
||||
## Phacelle Noise
|
||||
|
||||
`math.phacelle` and `cultmath_phacelle` follow Rune Skovbo Johansen's Phacelle
|
||||
noise (Shadertoy `t3dyWl`; erosion filter `wXcfWn`; blog, March 2026), read
|
||||
from the C# port `PhacelleNoise` in `lpmitchell/AdvancedTerrainErosion`
|
||||
(`AdvancedTerrainErosion.cs`, converted to burstable C# by Luke Mitchell,
|
||||
2026). The cell weight, the phase blend and the normalization follow it. The
|
||||
3D-cell generalisation, the caller-supplied stripe wave vector, the exact
|
||||
gradient and the exact pruning are new here.
|
||||
|
||||
- Copyright (c) 2025 Rune Skovbo Johansen.
|
||||
- Upstream: <https://github.com/lpmitchell/AdvancedTerrainErosion>
|
||||
- License: MPL-2.0 (<https://mozilla.org/MPL/2.0/>). MPL-2.0 is file-level, so it
|
||||
covers exactly the files that carry the port and nothing else:
|
||||
`src/CultMath/math.Phacelle.cs`, `src/CultMath/CultPhasor.cs`,
|
||||
`shaders/CultMath.Phacelle.hlsl` and the Unity copy of that include. Each
|
||||
carries the MPL-2.0 header and this attribution. The rest of CultMath is MIT,
|
||||
so the `GameCult.Math` NuGet package declares `MIT AND MPL-2.0` and ships this
|
||||
file. The Unity package as a whole is MPL-2.0.
|
||||
|
|
|
|||
Loading…
Reference in a new issue