Tooling
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package fluvial
|
||||
|
||||
import "salty/terrain/internal/world"
|
||||
|
||||
// Deterministic per-cell jitter, and why a router needs one.
|
||||
//
|
||||
// D8 lets a cell drain to one of eight neighbours, so every channel is a chain of 0, 45 and 90 degree
|
||||
@@ -9,28 +11,72 @@ package fluvial
|
||||
// flood's traversal geometry and draws it as rivers — ruler-straight diagonals, the polygonal network that
|
||||
// killed the first attempt at flat plains.
|
||||
//
|
||||
// The fix is to stop the epsilon being uniform. A hash of the cell index scatters it by plus or minus half,
|
||||
// which is far below anything that matters to the solve (a millimetre against metre-scale relief) and far
|
||||
// above the difference the flood's ordering would otherwise leave, so the descent direction on a flat is
|
||||
// decided by the hash rather than by scan order. The same hash breaks near-ties between two equally steep
|
||||
// neighbours, which is the other place a fixed direction order leaks a grid axis into the result.
|
||||
// The fix is to stop the epsilon being uniform. A hash scatters it by plus or minus half, which is far below
|
||||
// anything that matters to the solve (a millimetre against metre-scale relief) and far above the difference
|
||||
// the flood's ordering would otherwise leave, so the descent direction on a flat is decided by the hash
|
||||
// rather than by scan order. The same hash breaks near-ties between two equally steep neighbours, which is
|
||||
// the other place a fixed direction order leaks a grid axis into the result.
|
||||
//
|
||||
// It is a hash rather than a random source because cross-cutting rule 12 is determinism from a seed: the
|
||||
// value for a cell must not depend on how many cells were visited before it, on which goroutine ran, or on
|
||||
// how many steps the solve has taken.
|
||||
//
|
||||
// And it is a hash of a *world position* rather than of a grid index, which is rule 1 of the tiling plan in
|
||||
// Docs/Terrain-Next.md 3.3. A planet is solved one landmass at a time, so the same physical cell turns up in
|
||||
// grids of different widths at different offsets; keyed on the index it would jitter differently each time,
|
||||
// and every place two frames met would show it. Keyed on where the cell actually is, it cannot.
|
||||
|
||||
// hash01 is splitmix64 finalised to the unit interval. Cheap, no state, and well enough distributed that
|
||||
// neighbouring indices get unrelated values — which is the whole requirement here.
|
||||
func hash01(seed uint64, i int32) float32 {
|
||||
x := seed ^ (uint64(uint32(i)) * 0x9e3779b97f4a7c15)
|
||||
x ^= x >> 30
|
||||
x *= 0xbf58476d1ce4e5b9
|
||||
x ^= x >> 27
|
||||
x *= 0x94d049bb133111eb
|
||||
x ^= x >> 31
|
||||
return float32(x>>11) / float32(1<<53)
|
||||
// The k namespace. Every caller of hashXY picks a k, and two callers that share one get perfectly correlated
|
||||
// jitter - the clamp's allowance would track the router's tie-break in the same direction, which is exactly
|
||||
// the kind of hidden coupling that prints a texture nobody can attribute. They are named here so a new
|
||||
// caller has to pick a free one.
|
||||
const (
|
||||
jitterFloodEpsilon int32 = 0 // the priority-flood's per-cell fall across a flat (fluvial.go)
|
||||
jitterReceiverTie int32 = 1 // .. 8, one per D8 direction: the steepest-neighbour tie-break (fluvial.go)
|
||||
jitterReposeAllow int32 = 9 // .. 16, one per D8 direction: the repose clamp's allowance (repose.go)
|
||||
jitterReposeOrder int32 = 17 // the repose clamp's pop order (repose.go)
|
||||
)
|
||||
|
||||
// hashXY is splitmix64's finaliser over a weighted sum of the seed and the position. One finalising round,
|
||||
// because this is called eight times per cell per step - several hundred billion times over a planet bake -
|
||||
// and the requirement is only that neighbouring cells get unrelated values, not cryptographic quality. The
|
||||
// three odd constants are summed rather than exclusive-ored so that swapping x and y does not collide.
|
||||
func hashXY(seed uint64, x, y, k int32) float32 {
|
||||
h := seed ^ (uint64(uint32(x))*0x9e3779b97f4a7c15 +
|
||||
uint64(uint32(y))*0xc2b2ae3d27d4eb4f +
|
||||
uint64(uint32(k))*0x165667b19e3779f9)
|
||||
h ^= h >> 30
|
||||
h *= 0xbf58476d1ce4e5b9
|
||||
h ^= h >> 27
|
||||
h *= 0x94d049bb133111eb
|
||||
h ^= h >> 31
|
||||
return float32(h>>11) / float32(1<<53)
|
||||
}
|
||||
|
||||
// SetSeed ties the jitter to the run's seed, so two seeds do not share the same flat-routing geometry.
|
||||
// Zero is a perfectly good seed; it is the default and nothing depends on it being set.
|
||||
func (g *Grid) SetSeed(seed int64) { g.seed = uint64(seed)*0x9e3779b97f4a7c15 + 0x243f6a8885a308d3 }
|
||||
|
||||
// SetFrame says where on the planet this grid sits, which is what turns the jitter from an index hash into
|
||||
// a position hash. Without it a grid is its own world at the origin, which is what the square canvas is and
|
||||
// what every existing test expects, so it is optional and NewGrid does not require it.
|
||||
func (g *Grid) SetFrame(f world.Frame) {
|
||||
g.originX = int32(f.P.WrapX(f.X0))
|
||||
g.originY = int32(f.Y0)
|
||||
g.planetW = int32(f.P.W)
|
||||
}
|
||||
|
||||
// worldX and worldY map a grid cell to its planet cell.
|
||||
//
|
||||
// The wrap is a compare and a subtract rather than a modulo on purpose: originX is already inside the
|
||||
// planet and x is less than the planet's width, so the sum overshoots by at most one turn. A modulo here
|
||||
// would be a division in the router's innermost loop.
|
||||
func (g *Grid) worldX(x int) int32 {
|
||||
v := g.originX + int32(x)
|
||||
if g.planetW > 0 && v >= g.planetW {
|
||||
v -= g.planetW
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (g *Grid) worldY(y int) int32 { return g.originY + int32(y) }
|
||||
|
||||
Reference in New Issue
Block a user