From 1cedaebd0fbd00728d97a0f37a8458b6d25720d9 Mon Sep 17 00:00:00 2001 From: soypat Date: Wed, 4 Dec 2024 23:38:21 -0300 Subject: [PATCH 1/5] fix CPU ellipse; fix GPU vec2 ssbo; add polyssbo/translatemulti2d; 87% test coverage; --- README.md | 2 + cpu_evaluators.go | 67 +++++++++++++++++--- examples/ui-geb/uigeb.go | 2 +- forge/textsdf/font.go | 43 +++++++------ glbuild/glbuild.go | 10 +-- go.mod | 6 +- go.sum | 2 - gsdf.go | 8 ++- gsdf2d.go | 89 +++++++++++++++++++++++--- gsdf_gpu_test.go | 132 +++++++++++++++++++++++++++++---------- gsdf_test.go | 3 +- 11 files changed, 278 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index d09a406..b59f170 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ All images and shapes in readme were generated using this library. ## Features +- High test coverage (when GPU available, not the case in CI) + - Extremely coherent API design. - UI for visualizing parts, rendered directly from shaders. See [UI example](./examples/ui-mandala) by running `go run ./examples/ui-mandala` diff --git a/cpu_evaluators.go b/cpu_evaluators.go index d8bdd34..f89e26a 100644 --- a/cpu_evaluators.go +++ b/cpu_evaluators.go @@ -1,6 +1,8 @@ package gsdf import ( + "math" + "github.com/chewxy/math32" "github.com/soypat/glgl/math/ms1" "github.com/soypat/glgl/math/ms2" @@ -8,6 +10,13 @@ import ( "github.com/soypat/gsdf/gleval" ) +// minReduce takes element-wise minimum of arguments and stores to first argument. +func minReduce(d1AndDst, d2 []float32) { + for i := range d1AndDst { + d1AndDst[i] = math32.Min(d1AndDst[i], d2[i]) + } +} + func (u *sphere) Evaluate(pos []ms3.Vec, dist []float32, userData any) error { r := u.r for i, p := range pos { @@ -129,9 +138,7 @@ func (u *OpUnion) Evaluate(pos []ms3.Vec, dist []float32, userData any) error { if err != nil { return err } - for i, d := range dist { - dist[i] = math32.Min(d, auxDist[i]) - } + minReduce(dist, auxDist) } return nil } @@ -619,8 +626,8 @@ func (c *hex2D) Evaluate(pos []ms2.Vec, dist []float32, userData any) error { func (c *ellipse2D) Evaluate(pos []ms2.Vec, dist []float32, userData any) error { // https://iquilezles.org/articles/ellipsedist - a, b := c.a, c.b for i, p := range pos { + a, b := c.a, c.b p = ms2.AbsElem(p) if p.X > p.Y { p.X, p.Y = p.Y, p.X @@ -646,8 +653,8 @@ func (c *ellipse2D) Evaluate(pos []ms2.Vec, dist []float32, userData any) error co = (ry + signf(l)*rx + math32.Abs(g)/(rx*ry) - m) / 2 } else { h := 2 * m * n * math32.Sqrt(d) - s := signf(q+h) * math32.Pow(math32.Abs(q+h), 1./3.) - u := signf(q-h) * math32.Pow(math32.Abs(q-h), 1./3.) + s := signf(q+h) * math32.Cbrt(math32.Abs(q+h)) + u := signf(q-h) * math32.Cbrt(math32.Abs(q-h)) rx := -s - u - 4*c + 2*m2 ry := sqrt3 * (s - u) @@ -927,7 +934,6 @@ func (c *circarray) Evaluate(pos []ms3.Vec, dist []float32, userData any) error ncirc := float32(c.circleDiv) ninsm1 := float32(c.nInst - 1) for i, p := range pos { - pangle := math32.Atan2(p.Y, p.X) id := math32.Floor(pangle / angle) if id < 0 { @@ -958,9 +964,7 @@ func (c *circarray) Evaluate(pos []ms3.Vec, dist []float32, userData any) error if err != nil { return err } - for i, d := range dist { - dist[i] = math32.Min(d, dist1[i]) - } + minReduce(dist, dist1) return nil } @@ -1031,3 +1035,46 @@ func (l *lines2D) Evaluate(pos []ms2.Vec, dist []float32, userData any) error { } return nil } + +func (c *translateMulti2D) Evaluate(pos []ms2.Vec, dist []float32, userData any) error { + vp, err := gleval.GetVecPool(userData) + if err != nil { + return err + } + for i := range dist { + dist[i] = math.MaxFloat32 + } + d1 := vp.Float.Acquire(len(pos)) + defer vp.Float.Release(d1) + for _, p := range c.displacements { + t2d := translate2D{ + s: c.s, + p: p, + } + err = t2d.Evaluate(pos, d1, userData) + if err != nil { + return err + } + minReduce(dist, d1) + } + return nil +} + +func (c *rotation2D) Evaluate(pos []ms2.Vec, dist []float32, userData any) error { + sdf, err := gleval.AssertSDF2(c.s) + if err != nil { + return err + } + vp, err := gleval.GetVecPool(userData) + if err != nil { + return err + } + posTransf := vp.V2.Acquire(len(pos)) + defer vp.V2.Release(posTransf) + invT := c.tInv + for i, p := range pos { + posTransf[i] = ms2.MulMatVec(invT, p) + } + err = sdf.Evaluate(posTransf, dist, userData) + return err +} diff --git a/examples/ui-geb/uigeb.go b/examples/ui-geb/uigeb.go index 41fda69..b2d8bef 100644 --- a/examples/ui-geb/uigeb.go +++ b/examples/ui-geb/uigeb.go @@ -57,7 +57,7 @@ func scene(bld *gsdf.Builder) (glbuild.Shader3D, error) { sclG := ms2.DivElem(sz, szG) sclE := ms2.DivElem(sz, szE) sclB := ms2.DivElem(sz, szB) - fmt.Println(sclG, sclE, sclB) + // Create 3D letters. L := sz.Max() G3 := bld.Extrude(G, L) diff --git a/forge/textsdf/font.go b/forge/textsdf/font.go index 063793a..7e9b99d 100644 --- a/forge/textsdf/font.go +++ b/forge/textsdf/font.go @@ -28,7 +28,7 @@ type Font struct { // basicGlyphs optimized array access for common ASCII glyphs. basicGlyphs [lastBasic - firstBasic + 1]glyph // Other kinds of glyphs. - otherGlyphs map[rune]glyph + otherGlyphs map[rune]*glyph bld gsdf.Builder reltol float32 // Set by config or reset call if zeroed. } @@ -59,7 +59,7 @@ func (f *Font) reset() { f.basicGlyphs[i] = glyph{} } if f.otherGlyphs == nil { - f.otherGlyphs = make(map[rune]glyph) + f.otherGlyphs = make(map[rune]*glyph) } else { for k := range f.otherGlyphs { delete(f.otherGlyphs, k) @@ -133,30 +133,38 @@ func (f *Font) AdvanceWidth(c rune) float32 { // Glyph returns a SDF for a character defined by the argument rune. func (f *Font) Glyph(c rune) (_ glbuild.Shader2D, err error) { - var g glyph + g, err := f.glyph(c) + if err != nil { + return nil, err + } + return g.sdf, nil +} + +func (f *Font) glyph(c rune) (g *glyph, err error) { if c >= firstBasic && c <= lastBasic { // Basic ASCII glyph case. - g = f.basicGlyphs[c-firstBasic] + g = &f.basicGlyphs[c-firstBasic] if g.sdf == nil { // Glyph not yet created. create it. - g, err = f.makeGlyph(c) + gc, err := f.makeGlyph(c) if err != nil { return nil, err } - f.basicGlyphs[c-firstBasic] = g + *g = gc } - return g.sdf, nil + return g, nil } // Unicode or other glyph. g, ok := f.otherGlyphs[c] if !ok { - g, err = f.makeGlyph(c) + gc, err := f.makeGlyph(c) if err != nil { return nil, err } + g = &gc f.otherGlyphs[c] = g } - return g.sdf, nil + return g, nil } func (f *Font) scale() fixed.Int26_6 { @@ -219,8 +227,8 @@ func (f *Font) makeGlyph(char rune) (glyph, error) { func glyphCurve(bld *gsdf.Builder, points []truetype.Point, start, end int, tol, scale float32) (glbuild.Shader2D, bool, error) { var ( - sampler = ms2.Spline3Sampler{Spline: quadBezier, Tolerance: tol} - sum float32 + sampler = ms2.Spline3Sampler{Spline: quadBezier, Tolerance: tol} + windingSum float32 ) points = points[start:end] n := len(points) @@ -241,7 +249,7 @@ func glyphCurve(bld *gsdf.Builder, points []truetype.Point, start, end int, tol, // on-on Straight line. poly = append(poly, v0) i += 1 - sum += (v0.X - vPrev.X) * (v0.Y + vPrev.Y) + windingSum += (v0.X - vPrev.X) * (v0.Y + vPrev.Y) vPrev = v0 continue @@ -269,10 +277,10 @@ func glyphCurve(bld *gsdf.Builder, points []truetype.Point, start, end int, tol, } poly = append(poly, v0) // Append start point. poly = sampler.SampleBisect(poly, 4) - sum += (v0.X - vPrev.X) * (v0.Y + vPrev.Y) + windingSum += (v0.X - vPrev.X) * (v0.Y + vPrev.Y) vPrev = v0 } - return bld.NewPolygon(poly), sum > 0, bld.Err() + return bld.NewPolygon(poly), windingSum > 0, bld.Err() } func p2v(p truetype.Point, scale float32) ms2.Vec { @@ -282,12 +290,7 @@ func p2v(p truetype.Point, scale float32) ms2.Vec { } } -var quadBezier = ms2.NewSpline3([]float32{ - 1, 0, 0, 0, - -2, 2, 0, 0, - 1, -2, 1, 0, - 0, 0, 0, 0, -}) +var quadBezier = ms2.SplineBezierQuadratic() func onbits3(points []truetype.Point, start, end, i int) uint32 { n := end - start diff --git a/glbuild/glbuild.go b/glbuild/glbuild.go index d255461..845a6f5 100644 --- a/glbuild/glbuild.go +++ b/glbuild/glbuild.go @@ -481,8 +481,7 @@ func AppendShaderBufferDecl(dst []byte, BlockName, instanceName string, ssbo Sha return nil, errors.New("AppendShaderBufferDecl requires BlockName for a valid SSBO declaration") } - const std = "std140" // Subject to change, would be provided by ShaderBuffer. - typename, err := glTypename(ssbo.Element) + typename, std, err := glTypename(ssbo.Element) if err != nil { return dst, fmt.Errorf("typename failed for %q: %w", ssbo.NamePtr, err) } @@ -522,14 +521,15 @@ func (obj ShaderObject) Validate() error { } else if obj.Binding < 0 { return errors.New("shader object negative binding point") } - _, err := glTypename(obj.Element) + _, _, err := glTypename(obj.Element) if err != nil { return err } return nil } -func glTypename(tp reflect.Type) (typename string, err error) { +func glTypename(tp reflect.Type) (typename, std string, err error) { + std = "std430" switch tp { case reflect.TypeOf(md2.Vec{}): typename = "dvec2" @@ -568,7 +568,7 @@ func glTypename(tp reflect.Type) (typename string, err error) { default: err = fmt.Errorf("equivalent type not implemented for %s", tp.String()) } - return typename, err + return typename, std, err } // AppendShaderSource appends the GL code of a single shader to the dst byte buffer. If dst's diff --git a/go.mod b/go.mod index 5cb6beb..d37c144 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,13 @@ go 1.22.1 require ( github.com/chewxy/math32 v1.11.1 github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 - github.com/go-gl/glfw v0.0.0-20221017161538-93cebf72946b github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/soypat/glgl v0.0.0-20241124175250-a2463fe190a5 golang.org/x/image v0.22.0 ) -require golang.org/x/exp v0.0.0-20221230185412-738e83a70c30 // indirect +require ( + github.com/go-gl/glfw v0.0.0-20221017161538-93cebf72946b // indirect + golang.org/x/exp v0.0.0-20221230185412-738e83a70c30 // indirect +) diff --git a/go.sum b/go.sum index 684c6dd..678cdc0 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b h1:GgabKamyOY github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/soypat/glgl v0.0.0-20241121001014-cc8498d2a83d h1:kDdWM661L/RAxg0j4gV+18hky7/3Tvbhd8O6p8CLB7w= -github.com/soypat/glgl v0.0.0-20241121001014-cc8498d2a83d/go.mod h1:1LcEp6XHSMCI91WlJHzl/aW4Bp5v6yQOiYFyjrlk350= github.com/soypat/glgl v0.0.0-20241124175250-a2463fe190a5 h1:PyD0ceAopD2FDv3ddx99Q+h7QxIzDPPuOQiaZrRA7yU= github.com/soypat/glgl v0.0.0-20241124175250-a2463fe190a5/go.mod h1:1LcEp6XHSMCI91WlJHzl/aW4Bp5v6yQOiYFyjrlk350= golang.org/x/exp v0.0.0-20221230185412-738e83a70c30 h1:m9O6OTJ627iFnN2JIWfdqlZCzneRO6EEBsHXI25P8ws= diff --git a/gsdf.go b/gsdf.go index 5b88a88..cce4827 100644 --- a/gsdf.go +++ b/gsdf.go @@ -24,12 +24,16 @@ const ( epstol = 6e-7 ) +// Flags is a bitmask of values to control the functioning of the [Builder] type. type Flags uint64 const ( // FlagNoDimensionPanic controls panicking behavior on invalid shape dimension errors. // If set then these errors do not panic, instead storing the error for later inspection with [Builder.Err]. FlagNoDimensionPanic Flags = 1 << iota + // FlagUseShaderBuffers enforces the use of shader object for all newly built + // SDFs which require a dynamic array(s) to be rendered correctly. + FlagUseShaderBuffers ) // Builder wraps all SDF primitive and operation logic generation. @@ -42,8 +46,8 @@ type Builder struct { limVecGPU int } -func (bld *Builder) useGPU(n int) bool { - return bld.limVecGPU != 0 && n > bld.limVecGPU || n > 1 +func (bld *Builder) useGPU(_ int) bool { + return bld.flags&FlagUseShaderBuffers != 0 // bld.limVecGPU != 0 && n > bld.limVecGPU || n > 1 } func makeHashName[T any](dst []byte, name string, vec []T) []byte { diff --git a/gsdf2d.go b/gsdf2d.go index 3cf833e..981bf7f 100644 --- a/gsdf2d.go +++ b/gsdf2d.go @@ -1,6 +1,7 @@ package gsdf import ( + "errors" "fmt" "math" "strconv" @@ -538,30 +539,37 @@ type poly2D struct { // NewPolygon creates a polygon from a set of vertices. The polygon can be self-intersecting. func (bld *Builder) NewPolygon(vertices []ms2.Vec) glbuild.Shader2D { + err := bld.validatePolygon(vertices) + if err != nil { + bld.shapeErrorf(err.Error()) + } + poly := poly2D{vert: vertices} + if bld.useGPU(len(vertices)) { + return &polyGPU{poly2D: poly, bufname: makeHashName(nil, "ssboPoly", vertices)} + } + return &poly +} + +func (bld *Builder) validatePolygon(vertices []ms2.Vec) error { prevIdx := len(vertices) - 1 if vertices[0] == vertices[prevIdx] { vertices = vertices[:prevIdx] // Discard last vertex if equal to first (this algorithm closes automatically). prevIdx-- } if len(vertices) < 3 { - bld.shapeErrorf("polygon needs at least 3 distinct vertices") + return errors.New("polygon needs at least 3 distinct vertices") + } for i := range vertices { if math32.IsNaN(vertices[i].X) || math32.IsNaN(vertices[i].Y) { - bld.shapeErrorf("NaN value in vertices") + return errors.New("NaN value in vertices") } if vertices[i] == vertices[prevIdx] { - bld.shapeErrorf("found two consecutive equal vertices in polygon") + return errors.New("found two consecutive equal vertices in polygon") } prevIdx = i } - - poly := poly2D{vert: vertices} - if bld.useGPU(len(vertices)) { - // println("poly") - // return &polyGPU{poly2D: poly, bufname: makeHashName(nil, "ssboPoly", vertices)} - } - return &poly + return nil } func (c *poly2D) Bounds() ms2.Box { @@ -1301,3 +1309,64 @@ func (s *scale2D) AppendShaderBody(b []byte) []byte { func (u *scale2D) AppendShaderObjects(objects []glbuild.ShaderObject) []glbuild.ShaderObject { return objects } + +// TranslateMulti2D displaces N instances of s SDF to positions given by displacements of length N. +func (bld *Builder) TranslateMulti2D(s glbuild.Shader2D, displacements []ms2.Vec) glbuild.Shader2D { + if s == nil { + bld.nilsdf("TranslateMulti2D") + } + return &translateMulti2D{ + displacements: displacements, + s: s, + bufname: makeHashName(nil, "translateMulti2D", displacements), + } +} + +type translateMulti2D struct { + displacements []ms2.Vec + s glbuild.Shader2D + bufname []byte +} + +func (tm *translateMulti2D) Bounds() ms2.Box { + var bb ms2.Box + elemBox := tm.s.Bounds() + for i := range tm.displacements { + bb = bb.Union(elemBox.Add(tm.displacements[i])) + } + return bb +} + +func (tm *translateMulti2D) ForEach2DChild(userData any, fn func(userData any, s *glbuild.Shader2D) error) error { + return fn(userData, &tm.s) +} + +func (tm *translateMulti2D) AppendShaderName(b []byte) []byte { + b = append(b, "translateMulti2D_"...) + b = tm.s.AppendShaderName(b) + return b +} + +func (tm *translateMulti2D) AppendShaderBody(b []byte) []byte { + b = glbuild.AppendDefineDecl(b, "v", string(tm.bufname)) + b = fmt.Appendf(b, + `const int num = v.length(); + float d = 1.0e23; + for( int i=0; i Date: Wed, 4 Dec 2024 23:47:58 -0300 Subject: [PATCH 2/5] add gsdf.Builder to font config --- examples/ui-geb/uigeb.go | 2 ++ forge/textsdf/font.go | 12 ++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/examples/ui-geb/uigeb.go b/examples/ui-geb/uigeb.go index b2d8bef..fc5ed50 100644 --- a/examples/ui-geb/uigeb.go +++ b/examples/ui-geb/uigeb.go @@ -24,6 +24,7 @@ func scene(bld *gsdf.Builder) (glbuild.Shader3D, error) { var f textsdf.Font f.Configure(textsdf.FontConfig{ RelativeGlyphTolerance: 0.01, + Builder: bld, }) err := f.LoadTTFBytes(textsdf.ISO3098TTF()) if err != nil { @@ -89,6 +90,7 @@ func scene(bld *gsdf.Builder) (glbuild.Shader3D, error) { func main() { var bld gsdf.Builder + // bld.SetFlags(gsdf.FlagUseShaderBuffers) shape, err := scene(&bld) shape = bld.Scale(shape, 0.3) if err != nil { diff --git a/forge/textsdf/font.go b/forge/textsdf/font.go index 7e9b99d..804098c 100644 --- a/forge/textsdf/font.go +++ b/forge/textsdf/font.go @@ -16,9 +16,12 @@ import ( const firstBasic = '!' const lastBasic = '~' +var defaultBuilder = &gsdf.Builder{} + type FontConfig struct { // RelativeGlyphTolerance sets the permissible curve tolerance for glyphs. Must be between 0..1. If zero a reasonable value is chosen. RelativeGlyphTolerance float32 + Builder *gsdf.Builder } // Font implements font parsing and glyph (character) generation. @@ -29,7 +32,7 @@ type Font struct { basicGlyphs [lastBasic - firstBasic + 1]glyph // Other kinds of glyphs. otherGlyphs map[rune]*glyph - bld gsdf.Builder + bld *gsdf.Builder reltol float32 // Set by config or reset call if zeroed. } @@ -39,6 +42,11 @@ func (f *Font) Configure(cfg FontConfig) error { } f.reset() f.reltol = cfg.RelativeGlyphTolerance + if cfg.Builder != nil { + f.bld = cfg.Builder + } else { + f.bld = defaultBuilder + } return nil } @@ -188,7 +196,7 @@ func (f *Font) scaleout() float32 { func (f *Font) makeGlyph(char rune) (glyph, error) { g := &f.gb - bld := &f.bld + bld := f.bld idx := f.ttf.Index(char) scale := f.scale() From b4c7e8369702c3f1311d9389b67f81807b382c60 Mon Sep 17 00:00:00 2001 From: soypat Date: Sun, 8 Dec 2024 09:12:48 -0300 Subject: [PATCH 3/5] update README --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b59f170..697ab56 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,12 @@ for visualization or 3D printing file outputs. Quick jump to usage: [bolt exampl All images and shapes in readme were generated using this library. -![circle](https://github.com/user-attachments/assets/91c99f47-0c52-4cb1-83e7-452b03b69dff) ![bolt-example](https://github.com/user-attachments/assets/8da50871-2415-423f-beb3-0d78ad67c79e) +![circle](https://github.com/user-attachments/assets/91c99f47-0c52-4cb1-83e7-452b03b69dff) +![text](https://github.com/user-attachments/assets/73a90941-9279-449d-9f4d-3f2746af5dd5) ## Requirements + - [Go](https://go.dev/) - **Optional**: See latest requirements on [go-glfw](https://github.com/go-gl/glfw) if using GPU @@ -130,4 +132,4 @@ go run ./examples/fibonacci-showerhead -resdiv 350 36,16s user 0,76s system 100 ![iso-screw](https://github.com/user-attachments/assets/6bc987b9-d522-42a4-89df-71a20c3ae7ff) ![array-triangles](https://github.com/user-attachments/assets/6a479889-2836-464c-b8ea-82109a5aad13) -![geb-book-cover](https://github.com/user-attachments/assets/1ed945fb-5729-4028-bed8-26e0de3073ab) \ No newline at end of file +![geb-book-cover](https://github.com/user-attachments/assets/a6727481-07f3-4636-8e1c-9b1a02bb108f) \ No newline at end of file From 825bde6b8c095a8b293fa958a062414b091440fd Mon Sep 17 00:00:00 2001 From: soypat Date: Sun, 8 Dec 2024 10:17:43 -0300 Subject: [PATCH 4/5] fix uninitialized Font crash; fix polygon self-closing case; admit multiple identical SSBOs; color conversion additions --- examples/image-text/text.go | 28 +---- forge/textsdf/font.go | 5 +- glbuild/glbuild.go | 53 +++++++--- gsdf2d.go | 13 ++- gsdf_gpu_test.go | 8 ++ gsdfaux/color.go | 200 ++++++++++++++++++++++++++++++++++++ gsdfaux/gsdfaux.go | 40 -------- gsdfaux/gsdfaux_test.go | 24 +++++ 8 files changed, 285 insertions(+), 86 deletions(-) create mode 100644 gsdfaux/color.go create mode 100644 gsdfaux/gsdfaux_test.go diff --git a/examples/image-text/text.go b/examples/image-text/text.go index 61c1d87..ef37a16 100644 --- a/examples/image-text/text.go +++ b/examples/image-text/text.go @@ -8,8 +8,6 @@ import ( "runtime" "time" - "github.com/chewxy/math32" - "github.com/soypat/glgl/math/ms1" "github.com/soypat/gsdf" "github.com/soypat/gsdf/forge/textsdf" "github.com/soypat/gsdf/glbuild" @@ -66,32 +64,12 @@ func main() { charHeight := sdf2.Bounds().Size().Y edgeAliasing := charHeight / 1000 + conversion := gsdfaux.ColorConversionLinearGradient(edgeAliasing, color.Black, color.White) start := time.Now() - err = gsdfaux.RenderPNGFile(filename, sdf2, 300, blackAndWhite(edgeAliasing)) + err = gsdfaux.RenderPNGFile(filename, sdf2, 300, conversion) if err != nil { log.Fatal(err) } + _ = conversion fmt.Println("PNG file rendered to", filename, "in", time.Since(start)) } - -func blackAndWhite(edgeSmooth float32) func(d float32) color.Color { - if edgeSmooth <= 0 { - return blackAndWhiteNoSmoothing - } - return func(d float32) color.Color { - // Smoothstep anti-aliasing near the edge - blend := 0.5 + 0.5*math32.Tanh(d/edgeSmooth) - // Clamp blend to [0, 1] for valid grayscale values - blend = ms1.Clamp(blend, 0, 1) - // Convert blend to grayscale - grayValue := uint8(blend * 255) - return color.Gray{Y: grayValue} - } -} - -func blackAndWhiteNoSmoothing(d float32) color.Color { - if d < 0 { - return color.Black - } - return color.White -} diff --git a/forge/textsdf/font.go b/forge/textsdf/font.go index 804098c..5970ef0 100644 --- a/forge/textsdf/font.go +++ b/forge/textsdf/font.go @@ -44,8 +44,6 @@ func (f *Font) Configure(cfg FontConfig) error { f.reltol = cfg.RelativeGlyphTolerance if cfg.Builder != nil { f.bld = cfg.Builder - } else { - f.bld = defaultBuilder } return nil } @@ -76,6 +74,9 @@ func (f *Font) reset() { if f.reltol == 0 { f.reltol = 0.15 } + if f.bld == nil { + f.bld = defaultBuilder + } } type glyph struct { diff --git a/glbuild/glbuild.go b/glbuild/glbuild.go index 845a6f5..2470243 100644 --- a/glbuild/glbuild.go +++ b/glbuild/glbuild.go @@ -268,33 +268,49 @@ func (p *Programmer) writeShaders(w io.Writer, nodes []Shader) (n int, objs []Sh clear(p.names) p.scratch = p.scratch[:0] p.objsScratch = p.objsScratch[:0] - currentBase := 2 + const startBase = 2 + currentBase := startBase + objIdx := 0 for i := len(nodes) - 1; i >= 0; i-- { - // Start by generating Shader Objects. + // Start by generating all Shader Objects. node := nodes[i] - prevIdx := len(p.objsScratch) p.objsScratch = node.AppendShaderObjects(p.objsScratch) - newObjects := p.objsScratch[prevIdx:] - for i := range newObjects { - if newObjects[i].Binding != -1 { - return n, nil, fmt.Errorf("shader buffer object binding should be set to -1 until shader generated for %T, %q", unwraproot(node), newObjects[i].NamePtr) + newObjs := p.objsScratch[objIdx:] + OBJWRITE: + for i := range newObjs { + obj := &newObjs[i] + if obj.Binding != -1 { + return n, nil, fmt.Errorf("shader buffer object binding should be set to -1 until shader generated for %T, %q", unwraproot(node), obj.NamePtr) } - newObjects[i].Binding = currentBase - currentBase++ - obj := newObjects[i] nameHash := hash(obj.NamePtr, 0) _, nameConflict := p.names[nameHash] if nameConflict { + oldObjs := p.objsScratch[:objIdx] + for _, old := range oldObjs { + conflictFound := nameHash == hash(old.NamePtr, 0) + if !conflictFound { + continue + } + // Conflict found! + if obj.Data == old.Data && obj.Size == old.Size && obj.Element == old.Element { + continue OBJWRITE // Skip this object, is duplicate and already has been added. + } + break // Conflict is not identical. + } return n, nil, fmt.Errorf("shader buffer object name conflict resolution not implemented: %T has buffer conflicting name %q of type %s", unwraproot(node), obj.NamePtr, obj.Element.String()) } + obj.Binding = currentBase + currentBase++ p.names[nameHash] = nameHash - blockName := unsafe.String(&obj.NamePtr[0], len(obj.NamePtr)) + "Buffer" - p.scratch, err = AppendShaderBufferDecl(p.scratch, blockName, "", obj) + blockName := string(obj.NamePtr) + "Buffer" + p.scratch, err = AppendShaderBufferDecl(p.scratch, blockName, "", *obj) if err != nil { return n, nil, err } } + objIdx += len(newObjs) } + if len(p.scratch) > 0 { // Write shader buffer declarations if any. ngot, err := w.Write(p.scratch) @@ -602,6 +618,7 @@ func AppendAllNodes(dst []Shader, root Shader) ([]Shader, error) { children := []Shader{root} nextChild := 0 nilChild := errors.New("got nil child in AppendAllNodes") + // found := make(map[Shader]struct{}) for len(children[nextChild:]) > 0 { newChildren := children[nextChild:] for _, obj := range newChildren { @@ -618,6 +635,10 @@ func AppendAllNodes(dst []Shader, root Shader) ([]Shader, error) { if s == nil || *s == nil { return nilChild } + // if _, skip := found[*s]; skip { + // return nil + // } + // found[*s] = struct{}{} children = append(children, *s) return nil }) @@ -627,6 +648,10 @@ func AppendAllNodes(dst []Shader, root Shader) ([]Shader, error) { if s == nil || *s == nil { return nilChild } + // if _, skip := found[*s]; skip { + // return nil + // } + // found[*s] = struct{}{} children = append(children, *s) return nil }) @@ -638,6 +663,10 @@ func AppendAllNodes(dst []Shader, root Shader) ([]Shader, error) { if s == nil || *s == nil { return nilChild } + // if _, skip := found[*s]; skip { + // return nil + // } + // found[*s] = struct{}{} children = append(children, *s) return nil }) diff --git a/gsdf2d.go b/gsdf2d.go index 981bf7f..7bd8db3 100644 --- a/gsdf2d.go +++ b/gsdf2d.go @@ -539,7 +539,7 @@ type poly2D struct { // NewPolygon creates a polygon from a set of vertices. The polygon can be self-intersecting. func (bld *Builder) NewPolygon(vertices []ms2.Vec) glbuild.Shader2D { - err := bld.validatePolygon(vertices) + vertices, err := bld.validatePolygon(vertices) if err != nil { bld.shapeErrorf(err.Error()) } @@ -550,26 +550,25 @@ func (bld *Builder) NewPolygon(vertices []ms2.Vec) glbuild.Shader2D { return &poly } -func (bld *Builder) validatePolygon(vertices []ms2.Vec) error { +func (bld *Builder) validatePolygon(vertices []ms2.Vec) ([]ms2.Vec, error) { prevIdx := len(vertices) - 1 if vertices[0] == vertices[prevIdx] { vertices = vertices[:prevIdx] // Discard last vertex if equal to first (this algorithm closes automatically). prevIdx-- } if len(vertices) < 3 { - return errors.New("polygon needs at least 3 distinct vertices") - + return vertices, errors.New("polygon needs at least 3 distinct vertices") } for i := range vertices { if math32.IsNaN(vertices[i].X) || math32.IsNaN(vertices[i].Y) { - return errors.New("NaN value in vertices") + return vertices, errors.New("NaN value in vertices") } if vertices[i] == vertices[prevIdx] { - return errors.New("found two consecutive equal vertices in polygon") + return vertices, errors.New("found two consecutive equal vertices in polygon") } prevIdx = i } - return nil + return vertices, nil } func (c *poly2D) Bounds() ms2.Box { diff --git a/gsdf_gpu_test.go b/gsdf_gpu_test.go index 4b68ce2..93a02a7 100644 --- a/gsdf_gpu_test.go +++ b/gsdf_gpu_test.go @@ -107,6 +107,10 @@ func testGsdfGPU() error { if t.fail { return fmt.Errorf("%s: test failed", getFnName(test)) } + bldErr := cfg.bld.Err() + if bldErr != nil { + return fmt.Errorf("%s: got Builder error %q", getFnName(test), bldErr.Error()) + } } return nil } @@ -226,12 +230,15 @@ func testPrimitives2D(t *tb, cfg *shaderTestConfig) { // Non-SSBO shapes which use dynamic buffers. poly := bld.NewPolygon(vertices) + polySelfClosed := bld.NewPolygon([]ms2.Vec{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 1, Y: 1}, {X: 0, Y: 0}}) // Create shapes to test usage of dynamic buffers as SSBOs. bld.SetFlags(bld.Flags() | FlagUseShaderBuffers) + polySSBO := bld.NewPolygon(vertices) linesSSBO := bld.NewLines2D(segments, 0.1) displaceSSBO := bld.TranslateMulti2D(poly, vertices) + bld.SetFlags(bld.Flags() &^ FlagUseShaderBuffers) var primitives = []glbuild.Shader2D{ @@ -243,6 +250,7 @@ func testPrimitives2D(t *tb, cfg *shaderTestConfig) { bld.NewEquilateralTriangle(maxdim), bld.NewEllipse(1, 2), // Is incorrect. poly, + polySelfClosed, polySSBO, linesSSBO, displaceSSBO, diff --git a/gsdfaux/color.go b/gsdfaux/color.go new file mode 100644 index 0000000..19fae7f --- /dev/null +++ b/gsdfaux/color.go @@ -0,0 +1,200 @@ +package gsdfaux + +import ( + "image/color" + + math "github.com/chewxy/math32" + "github.com/soypat/glgl/math/ms1" + "github.com/soypat/glgl/math/ms3" +) + +// A great portion of logic in this file taken from Esme Lamb's (@dedelala) +// excellent color manipulation work presented at Gophercon AU 2024. +// https://github.com/dedelala/disco/tree/main/color + +var red = color.RGBA{R: 255, A: 255} + +// ColorConversionInigoQuilez creates a new color conversion using [Inigo Quilez]'s style. +// A good value for characteristic distance is the bounding box diagonal divided by 3. Returns red for NaN values. +// +// [Inigo Quilez]: https://iquilezles.org/articles/distfunctions2d/ +func ColorConversionInigoQuilez(characteristicDistance float32) func(float32) color.Color { + inv := 1. / characteristicDistance + return func(d float32) color.Color { + if math.IsNaN(d) { + return red + } + d *= inv + var one = ms3.Vec{X: 1, Y: 1, Z: 1} + var c ms3.Vec + if d > 0 { + c = ms3.Vec{X: 0.9, Y: 0.6, Z: 0.3} + } else { + c = ms3.Vec{X: 0.65, Y: 0.85, Z: 1.0} + } + c = ms3.Scale(1-math.Exp(-6*math.Abs(d)), c) + c = ms3.Scale(0.8+0.2*math.Cos(150*d), c) + max := 1 - ms1.SmoothStep(0, 0.01, math.Abs(d)) + c = ms3.InterpElem(c, one, ms3.Vec{X: max, Y: max, Z: max}) + return color.RGBA{ + R: uint8(c.X * 255), + G: uint8(c.Y * 255), + B: uint8(c.Z * 255), + A: 255, + } + } +} + +// ColorConversionLinearGradient creates a color conversion function that creates a gradient centered +// along d=0 that extends gradientLength. +func ColorConversionLinearGradient(gradientLength float32, c0, c1 color.Color) func(d float32) color.Color { + if c0 == color.Black && c1 == color.White { + return blackAndWhiteLinearSmooth(gradientLength) + } + h0, s0, v0 := colorToHSV(c0) + h1, s1, v1 := colorToHSV(c1) + return func(d float32) color.Color { + // Smoothstep anti-aliasing near the edge + blend := d/gradientLength + 0.5 + if blend <= 0 { + return c0 + } else if blend >= 1 { + return c1 + } + // Clamp blend to [0, 1] for colors in gradient range. + h, s, v := interpHSV(h0, s0, v0, h1, s1, v1, blend) + r, g, b := hsvToRGB(h, s, v) + c := rgbToC(r, g, b) + // Convert blend to gradient. + return color.RGBA{R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c), A: 255} + } +} + +func blackAndWhiteLinearSmooth(edgeSmooth float32) func(d float32) color.Color { + if edgeSmooth == 0 { + return blackAndWhiteNoSmoothing + } + return func(d float32) color.Color { + // Smoothstep anti-aliasing near the edge + blend := d/edgeSmooth + 0.5 + // blend := 0.5 + 0.5*math32.Tanh(x) + if blend <= 0 { + return color.Black + } else if blend >= 1 { + return color.White + } + // Clamp blend to [0, 1] for valid grayscale values + blend = ms1.Clamp(blend, 0, 1) + // Convert blend to grayscale + grayValue := uint8(blend * 255) + return color.Gray{Y: grayValue} + } +} + +func blackAndWhiteNoSmoothing(d float32) color.Color { + if d < 0 { + return color.Black + } + return color.White +} + +func percentUint64(num, denom uint64) float32 { + return math.Trunc(10000*float32(num)/float32(denom)) / 100 +} + +func cInterp(c0, c1 uint32, t float32) uint32 { + h0, s0, v0 := rgbToHSV(cToRGB(c0)) + h1, s1, v1 := rgbToHSV(cToRGB(c1)) + return rgbToC(hsvToRGB(interpHSV(h0, s0, v0, h1, s1, v1, t))) +} + +func interpHSV(h0, s0, v0, h1, s1, v1, t float32) (h, s, v float32) { + switch { + case h1-h0 > 0.5: + h0 += 1.0 + case h1-h0 < -0.5: + h1 += 1.0 + } + h = ms1.Interp(h0, h1, t) + s = ms1.Interp(s0, s1, t) + v = ms1.Interp(v0, v1, t) + return h, s, v +} + +func colorToHSV(c color.Color) (h, s, v float32) { + r0, g0, b0, _ := c.RGBA() + return rgbToHSV(float32(r0>>8)/math.MaxUint8, float32(g0>>8)/math.MaxUint8, float32(b0>>8)/math.MaxUint8) +} + +// cToRGB converts a 24 bit RGB value stored in the least significant bits +func cToRGB(c uint32) (r, g, b float32) { + r = float32(uint8(c>>16)) / math.MaxUint8 + g = float32(uint8(c>>8)) / math.MaxUint8 + b = float32(uint8(c)) / math.MaxUint8 + return r, g, b +} + +// rgbToC converts r, g, and b float64 values on the range of 0.0 to 1.0 to a +// 24 bit RGB value stored in the least significant bits of a uint32. The inputs +// are clamped to the range of 0.0 to 1.0 +func rgbToC(r, g, b float32) (c uint32) { + return uint32(ms1.Clamp(r, 0, 1)*math.MaxUint8)<<16 | + uint32(ms1.Clamp(g, 0, 1)*math.MaxUint8)<<8 | + uint32(ms1.Clamp(b, 0, 1)*math.MaxUint8) +} + +// hsvToRGB converts hue, saturation and brightness values on the range of 0.0 +// to 1.0 to RGB floating point values on the range of 0.0 to 1.0 +func hsvToRGB(h, s, v float32) (r, g, b float32) { + var ( + c = s * v + x = c * (1 - math.Abs(math.Mod(h*6, 2)-1)) + m = v - c + ) + + switch { + case h >= 0 && h <= 1.0/6: + r, g, b = c, x, 0 + case h > 1.0/6 && h <= 2.0/6: + r, g, b = x, c, 0 + case h > 2.0/6 && h <= 3.0/6: + r, g, b = 0, c, x + case h > 3.0/6 && h <= 4.0/6: + r, g, b = 0, x, c + case h > 4.0/6 && h <= 5.0/6: + r, g, b = x, 0, c + case h > 5.0/6 && h <= 1.0: + r, g, b = c, 0, x + } + + r, g, b = r+m, g+m, b+m + return r, g, b +} + +// rgbToHSV converts red, green, and blue floating point values on the range +// 0.0 to 1.0 to hue, saturation and brightness values on the range 0.0 to 1.0 +func rgbToHSV(r, g, b float32) (h, s, v float32) { + var ( + xmax = max(r, g, b) + xmin = min(r, g, b) + c = xmax - xmin + ) + v = xmax + switch { + case c == 0: + h = 0 + case v == r: + h = (g - b) / (c * 6) + case v == g: + h = 1.0/3 + (b-r)/(c*6) + case v == b: + h = 2.0/3 + (r-g)/(c*6) + } + if h < 0 { + h += 1 + } + if xmax > 0 { + s = c / xmax + } + return +} diff --git a/gsdfaux/gsdfaux.go b/gsdfaux/gsdfaux.go index 3844c01..2cf623f 100644 --- a/gsdfaux/gsdfaux.go +++ b/gsdfaux/gsdfaux.go @@ -13,10 +13,7 @@ import ( "time" - "github.com/chewxy/math32" math "github.com/chewxy/math32" - "github.com/soypat/glgl/math/ms1" - "github.com/soypat/glgl/math/ms3" "github.com/soypat/glgl/v4.6-core/glgl" "github.com/soypat/gsdf" "github.com/soypat/gsdf/glbuild" @@ -273,40 +270,3 @@ func MakeGPUSDF2(s glbuild.Shader2D) (sdf gleval.SDF2, err error) { ShaderObjects: objects, }) } - -var red = color.RGBA{R: 255, A: 255} - -// ColorConversionInigoQuilez creates a new color conversion using [Inigo Quilez]'s style. -// A good value for characteristic distance is the bounding box diagonal divided by 3. Returns red for NaN values/ -// -// [Inigo Quilez]: https://iquilezles.org/articles/distfunctions2d/ -func ColorConversionInigoQuilez(characteristicDistance float32) func(float32) color.Color { - inv := 1. / characteristicDistance - return func(d float32) color.Color { - if math.IsNaN(d) { - return red - } - d *= inv - var one = ms3.Vec{X: 1, Y: 1, Z: 1} - var c ms3.Vec - if d > 0 { - c = ms3.Vec{X: 0.9, Y: 0.6, Z: 0.3} - } else { - c = ms3.Vec{X: 0.65, Y: 0.85, Z: 1.0} - } - c = ms3.Scale(1-math32.Exp(-6*math32.Abs(d)), c) - c = ms3.Scale(0.8+0.2*math32.Cos(150*d), c) - max := 1 - ms1.SmoothStep(0, 0.01, math32.Abs(d)) - c = ms3.InterpElem(c, one, ms3.Vec{X: max, Y: max, Z: max}) - return color.RGBA{ - R: uint8(c.X * 255), - G: uint8(c.Y * 255), - B: uint8(c.Z * 255), - A: 255, - } - } -} - -func percentUint64(num, denom uint64) float32 { - return math.Trunc(10000*float32(num)/float32(denom)) / 100 -} diff --git a/gsdfaux/gsdfaux_test.go b/gsdfaux/gsdfaux_test.go new file mode 100644 index 0000000..79b8949 --- /dev/null +++ b/gsdfaux/gsdfaux_test.go @@ -0,0 +1,24 @@ +package gsdfaux + +import ( + "image" + "image/color" + "image/png" + "os" + "testing" +) + +func TestColorGradient(t *testing.T) { + const Xdim = 256 + img := image.NewRGBA(image.Rect(0, 0, Xdim, Xdim)) + conv := ColorConversionLinearGradient(Xdim, color.White, red) + var x float32 = -Xdim / 2 + for i := range Xdim { + for j := range Xdim { + img.Set(i, j, conv(x)) + } + x += 1 + } + fp, _ := os.Create("test.png") + png.Encode(fp, img) +} From d9e10de28ba809fa446f81537db89711252d8308 Mon Sep 17 00:00:00 2001 From: soypat Date: Wed, 18 Dec 2024 18:12:39 -0300 Subject: [PATCH 5/5] rewrite tests to run on CPU; add Scale2D CPU evaluator; bump glgl version --- cpu_evaluators.go | 23 ++ go.mod | 2 +- go.sum | 2 + gsdf.go | 62 ++--- gsdf_gpu_test.go | 650 +------------------------------------------ gsdf_test.go | 695 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 750 insertions(+), 684 deletions(-) diff --git a/cpu_evaluators.go b/cpu_evaluators.go index f89e26a..24ac332 100644 --- a/cpu_evaluators.go +++ b/cpu_evaluators.go @@ -1078,3 +1078,26 @@ func (c *rotation2D) Evaluate(pos []ms2.Vec, dist []float32, userData any) error err = sdf.Evaluate(posTransf, dist, userData) return err } + +func (c *scale2D) Evaluate(pos []ms2.Vec, dist []float32, userData any) error { + sdf, err := gleval.AssertSDF2(c.s) + if err != nil { + return err + } + vp, err := gleval.GetVecPool(userData) + if err != nil { + return err + } + posTransf := vp.V2.Acquire(len(pos)) + defer vp.V2.Release(posTransf) + invScale := 1. / c.scale + for i, p := range pos { + posTransf[i] = ms2.Scale(invScale, p) + } + err = sdf.Evaluate(posTransf, dist, userData) + scale := c.scale + for i, d := range dist { + dist[i] = d * scale + } + return err +} diff --git a/go.mod b/go.mod index d37c144..ade68c7 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 - github.com/soypat/glgl v0.0.0-20241124175250-a2463fe190a5 + github.com/soypat/glgl v0.0.0-20241218113040-663b03b49704 golang.org/x/image v0.22.0 ) diff --git a/go.sum b/go.sum index 678cdc0..8009095 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF0 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/soypat/glgl v0.0.0-20241124175250-a2463fe190a5 h1:PyD0ceAopD2FDv3ddx99Q+h7QxIzDPPuOQiaZrRA7yU= github.com/soypat/glgl v0.0.0-20241124175250-a2463fe190a5/go.mod h1:1LcEp6XHSMCI91WlJHzl/aW4Bp5v6yQOiYFyjrlk350= +github.com/soypat/glgl v0.0.0-20241218113040-663b03b49704 h1:KU+Ofl/VEFFM/uNpDPu+2Ds8RrkJUu21Ef0klG7xK08= +github.com/soypat/glgl v0.0.0-20241218113040-663b03b49704/go.mod h1:1LcEp6XHSMCI91WlJHzl/aW4Bp5v6yQOiYFyjrlk350= golang.org/x/exp v0.0.0-20221230185412-738e83a70c30 h1:m9O6OTJ627iFnN2JIWfdqlZCzneRO6EEBsHXI25P8ws= golang.org/x/exp v0.0.0-20221230185412-738e83a70c30/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= diff --git a/gsdf.go b/gsdf.go index cce4827..f51d25f 100644 --- a/gsdf.go +++ b/gsdf.go @@ -2,7 +2,6 @@ package gsdf import ( _ "embed" - "encoding/binary" "errors" "fmt" "unsafe" @@ -134,16 +133,6 @@ func absf(a float32) float32 { return math32.Abs(a) } -func hashvec2(vecs ...ms2.Vec) float32 { - var hashA float32 = 0.0 - var hashB float32 = 1.0 - for _, v := range vecs { - hashA, hashB = hashAdd(hashA, hashB, v.X) - hashA, hashB = hashAdd(hashA, hashB, v.Y) - } - return hashfint(hashA + hashB) -} - func hash2vec2(vecs ...[2]ms2.Vec) float32 { var hashA float32 = 0.0 var hashB float32 = 1.0 @@ -156,17 +145,6 @@ func hash2vec2(vecs ...[2]ms2.Vec) float32 { return hashfint(hashA + hashB) } -func hashvec3(vecs ...ms3.Vec) float32 { - var hashA float32 = 0.0 - var hashB float32 = 1.0 - for _, v := range vecs { - hashA, hashB = hashAdd(hashA, hashB, v.X) - hashA, hashB = hashAdd(hashA, hashB, v.Y) - hashA, hashB = hashAdd(hashA, hashB, v.Z) - } - return hashfint(hashA + hashB) -} - func hashf(values []float32) float32 { var hashA float32 = 0.0 var hashB float32 = 1.0 @@ -189,26 +167,26 @@ func hashfint(f float32) float32 { return float32(int(f*1000000)%1000000) / 1000000 // Keep within [0.0, 1.0) } -func hash(b []byte, in uint64) uint64 { - x := in - for len(b) >= 8 { - x ^= binary.LittleEndian.Uint64(b) - x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9 - x = (x ^ (x >> 27)) * 0x94d049bb133111eb - x ^= x >> 31 - b = b[8:] - - } - if len(b) > 0 { - var buf [8]byte - copy(buf[:], b) - x ^= binary.LittleEndian.Uint64(buf[:]) - x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9 - x = (x ^ (x >> 27)) * 0x94d049bb133111eb - x ^= x >> 31 - } - return x -} +// func hash(b []byte, in uint64) uint64 { +// x := in +// for len(b) >= 8 { +// x ^= binary.LittleEndian.Uint64(b) +// x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9 +// x = (x ^ (x >> 27)) * 0x94d049bb133111eb +// x ^= x >> 31 +// b = b[8:] + +// } +// if len(b) > 0 { +// var buf [8]byte +// copy(buf[:], b) +// x ^= binary.LittleEndian.Uint64(buf[:]) +// x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9 +// x = (x ^ (x >> 27)) * 0x94d049bb133111eb +// x ^= x >> 31 +// } +// return x +// } func hashshaderptr(s glbuild.Shader) uint64 { v := *(*[2]uintptr)(unsafe.Pointer(&s)) diff --git a/gsdf_gpu_test.go b/gsdf_gpu_test.go index 93a02a7..4ef732f 100644 --- a/gsdf_gpu_test.go +++ b/gsdf_gpu_test.go @@ -4,681 +4,49 @@ package gsdf import ( "bytes" - "errors" - "fmt" "log" "math" - "math/rand" "os" - "reflect" "runtime" - "strings" "testing" "time" - "github.com/chewxy/math32" "github.com/go-gl/gl/v4.6-core/gl" "github.com/go-gl/glfw/v3.3/glfw" - "github.com/soypat/glgl/math/ms1" - "github.com/soypat/glgl/math/ms2" - "github.com/soypat/glgl/math/ms3" "github.com/soypat/glgl/v4.6-core/glgl" "github.com/soypat/gsdf/glbuild" "github.com/soypat/gsdf/gleval" ) -var failedObj glbuild.Shader3D - -type shaderTestConfig struct { - bld *Builder - posbufs [4][]ms3.Vec - posbuf2s [4][]ms2.Vec - distbuf [4][]float32 - testres float32 - vp gleval.VecPool - prog glbuild.Programmer - progbuf bytes.Buffer - rng *rand.Rand -} - -func (cfg *shaderTestConfig) div3(bounds ms3.Box) (int, int, int) { - sz := bounds.Size() - nx, ny, nz := cfg.div(sz.X), cfg.div(sz.Y), cfg.div(sz.Z) - return nx, ny, nz -} -func (cfg *shaderTestConfig) div2(bounds ms2.Box) (int, int) { - sz := bounds.Size() - nx, ny := cfg.div(sz.X), cfg.div(sz.Y) - return nx, ny -} -func (cfg *shaderTestConfig) div(dim float32) int { - divs := dim / cfg.testres - return int(ms1.Clamp(divs, 5, 32)) -} - // Since GPU must be run in main thread we need to do some dark arts for GPU code to be code-covered. func TestMain(m *testing.M) { runtime.LockOSThread() var exit int - err := testGsdfGPU() + cfg := newShaderTestConfig() + err := testGsdfGPU(cfg) if err != nil { exit = 1 log.Println(err) } - if failedObj != nil { - ui(failedObj, 800, 600) + if cfg.failedObj != nil { + ui(cfg.failedObj, 800, 600) } runtime.UnlockOSThread() os.Exit(m.Run() | exit) } -func testGsdfGPU() error { - const bufsize = 32 * 32 * 32 +func testGsdfGPU(cfg *shaderTestConfig) error { term, err := gleval.Init1x1GLFW() if err != nil { log.Fatal(err) } defer term() invoc := glgl.MaxComputeInvocations() - prog := *glbuild.NewDefaultProgrammer() + prog := glbuild.NewDefaultProgrammer() prog.SetComputeInvocations(invoc, 1, 1) - cfg := &shaderTestConfig{ - testres: 1. / 3, - prog: prog, - rng: rand.New(rand.NewSource(1)), - bld: &Builder{}, - } - for i := range cfg.posbuf2s { - cfg.posbuf2s[i] = make([]ms2.Vec, bufsize) - cfg.posbufs[i] = make([]ms3.Vec, bufsize) - cfg.distbuf[i] = make([]float32, bufsize) - } - t := &tb{} - var tests = []func(*tb, *shaderTestConfig){ - testPrimitives3D, - testPrimitives2D, - testBinOp3D, - testRandomUnary3D, - testBinary2D, - testRandomUnary2D, - } - for _, test := range tests { - test(t, cfg) - if t.fail { - return fmt.Errorf("%s: test failed", getFnName(test)) - } - bldErr := cfg.bld.Err() - if bldErr != nil { - return fmt.Errorf("%s: got Builder error %q", getFnName(test), bldErr.Error()) - } - } - return nil -} - -func testPrimitives3D(t *tb, cfg *shaderTestConfig) { - bld := cfg.bld - const maxdim float32 = 1.0 - dimVec := ms3.Vec{X: maxdim, Y: maxdim * 0.47, Z: maxdim * 0.8} - thick := maxdim / 10 - var primitives = []glbuild.Shader3D{ - bld.NewSphere(1), - bld.NewBox(dimVec.X, dimVec.Y, dimVec.Z, thick), - bld.NewBoxFrame(dimVec.X, dimVec.Y, dimVec.Z, thick), - bld.NewCylinder(dimVec.X, dimVec.Y, 0), - bld.NewCylinder(dimVec.X, dimVec.Y, thick), - bld.NewHexagonalPrism(dimVec.X, dimVec.Y), - bld.NewTorus(dimVec.X, dimVec.Y), - } - for _, primitive := range primitives { - testShader3D(t, primitive, cfg) - } -} - -func testBinOp3D(t *tb, cfg *shaderTestConfig) { - bld := cfg.bld - unionBin := func(a, b glbuild.Shader3D) glbuild.Shader3D { - return bld.Union(a, b) - } - var BinaryOps = []func(a, b glbuild.Shader3D) glbuild.Shader3D{ - unionBin, - bld.Difference, - bld.Intersection, - bld.Xor, - } - var smoothOps = []func(k float32, a, b glbuild.Shader3D) glbuild.Shader3D{ - bld.SmoothUnion, - bld.SmoothDifference, - bld.SmoothIntersect, - } - - s1 := bld.NewSphere(1) - s2 := bld.NewBox(1, 0.6, .8, 0.1) - s2 = bld.Translate(s2, 0.5, 0.7, 0.8) - for _, op := range BinaryOps { - result := op(s1, s2) - testShader3D(t, result, cfg) - } - for _, op := range smoothOps { - result := op(0.1, s1, s2) - testShader3D(t, result, cfg) - } -} - -func testRandomUnary2D(t *tb, cfg *shaderTestConfig) { - bld := cfg.bld - obj := bld.NewRectangle(1, 0.61) - obj = bld.Translate2D(obj, 2, .3) - var RandUnary2D = []func(*Builder, glbuild.Shader2D, *rand.Rand) glbuild.Shader2D{ - randomArray2D, // Not sure why does not work. - randomCircArray2D, - randomSymmetry2D, - randomRotation2D, - randomAnnulus, - randomOffset2D, - } - for _, op := range RandUnary2D { - for i := 0; i < 10; i++ { - result := op(bld, obj, cfg.rng) - testShader2D(t, result, cfg) - } - } -} - -func testRandomUnary3D(t *tb, cfg *shaderTestConfig) { - bld := cfg.bld - var UnaryRandomizedOps = []func(*Builder, glbuild.Shader3D, *rand.Rand) glbuild.Shader3D{ - randomRotation, - randomShell, - randomElongate, - randomRound, - randomScale, - randomSymmetry, - randomTranslate, - randomArray, - randomCircArray, - } - var OtherUnaryRandomizedOps2D3D = []func(*Builder, glbuild.Shader2D, *rand.Rand) glbuild.Shader3D{ - randomExtrude, - randomRevolve, - } - s2 := bld.NewBox(1, 0.61, 0.8, 0.3) - for _, op := range UnaryRandomizedOps { - result := op(bld, s2, cfg.rng) - testShader3D(t, result, cfg) - } - s2d := &rect2D{d: ms2.Vec{X: 1, Y: 0.57}} - for _, op := range OtherUnaryRandomizedOps2D3D { - result := op(bld, s2d, cfg.rng) - testShader3D(t, result, cfg) - } -} - -func testPrimitives2D(t *tb, cfg *shaderTestConfig) { - const maxdim float32 = 1.0 - var pbuilder ms2.PolygonBuilder - pbuilder.Nagon(8, 1) - vertices, _ := pbuilder.AppendVecs(nil) - vPrev := vertices[len(vertices)-1] - var segments [][2]ms2.Vec - for i := 0; i < len(vertices); i++ { - segments = append(segments, [2]ms2.Vec{vPrev, vertices[i]}) - vPrev = vertices[i] - } - bld := cfg.bld - dimVec := ms2.Vec{X: maxdim, Y: maxdim * 0.47} - thick := maxdim / 10 - - // Non-SSBO shapes which use dynamic buffers. - poly := bld.NewPolygon(vertices) - polySelfClosed := bld.NewPolygon([]ms2.Vec{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 1, Y: 1}, {X: 0, Y: 0}}) - - // Create shapes to test usage of dynamic buffers as SSBOs. - bld.SetFlags(bld.Flags() | FlagUseShaderBuffers) - - polySSBO := bld.NewPolygon(vertices) - linesSSBO := bld.NewLines2D(segments, 0.1) - displaceSSBO := bld.TranslateMulti2D(poly, vertices) - - bld.SetFlags(bld.Flags() &^ FlagUseShaderBuffers) - - var primitives = []glbuild.Shader2D{ - bld.NewCircle(maxdim), - bld.NewLine2D(0, 0, dimVec.X, dimVec.Y, thick), - bld.NewRectangle(dimVec.X, dimVec.Y), - bld.NewArc(dimVec.X, math.Pi/3, thick), - bld.NewHexagon(maxdim), - bld.NewEquilateralTriangle(maxdim), - bld.NewEllipse(1, 2), // Is incorrect. - poly, - polySelfClosed, - polySSBO, - linesSSBO, - displaceSSBO, - } - for _, primitive := range primitives { - testShader2D(t, primitive, cfg) - } -} - -func testBinary2D(t *tb, cfg *shaderTestConfig) { - bld := cfg.bld - union := func(a, b glbuild.Shader2D) glbuild.Shader2D { - return bld.Union2D(a, b) - } - s2 := bld.NewRectangle(1, 0.61) - s1 := bld.NewCircle(0.4) - s1 = bld.Translate2D(s1, 0.45, 1) - var BinaryOps2D = []func(a, b glbuild.Shader2D) glbuild.Shader2D{ - union, - bld.Difference2D, - bld.Intersection2D, - bld.Xor2D, - } - for _, op := range BinaryOps2D { - result := op(s1, s2) - testShader2D(t, result, cfg) - } -} - -func testShader3D(t *tb, obj glbuild.Shader3D, cfg *shaderTestConfig) { - bld := cfg.bld - vp := &cfg.vp - bounds := obj.Bounds() - invocx, _, _ := cfg.prog.ComputeInvocations() - nx, ny, nz := cfg.div3(bounds) - - pos := ms3.AppendGrid(cfg.posbufs[0][:0], bounds, nx, ny, nz) - distCPU := cfg.distbuf[0][:len(pos)] - distGPU := cfg.distbuf[1][:len(pos)] - - // Do CPU evaluation. - sdfcpu, err := gleval.AssertSDF3(obj) - if err != nil { - t.Fatal(err) - } - err = sdfcpu.Evaluate(pos, distCPU, vp) - if err != nil { - t.Fatal(err) - } - // Do GPU evaluation. - cfg.progbuf.Reset() - n, objs, err := cfg.prog.WriteComputeSDF3(&cfg.progbuf, obj) - if err != nil { - t.Fatal(err) - } - if n != cfg.progbuf.Len() { - t.Fatalf("written bytes not match length of buffer %d != %d", n, cfg.progbuf.Len()) - } - sdfgpu, err := gleval.NewComputeGPUSDF3(&cfg.progbuf, bounds, gleval.ComputeConfig{ - InvocX: invocx, - ShaderObjects: objs, - }) - if err != nil { - t.Fatal(err) - } - err = sdfgpu.Evaluate(pos, distGPU, nil) - if err != nil { - t.Fatal(err) - } - err = cmpDist(t, pos, distCPU, distGPU) - if err != nil { - name := appendShaderName(nil, obj) - t.Errorf("%s: %s", name, err) - } - err = test_bounds(sdfcpu, vp, cfg) - if err != nil { - bf := bld.NewBoundsBoxFrame(obj.Bounds()) - obj = bld.Union(obj, bf) - name := appendShaderName(nil, obj) - t.Errorf("%s: %s", name, err) - failedObj = obj - } -} - -func testShader2D(t *tb, obj glbuild.Shader2D, cfg *shaderTestConfig) { - bounds := obj.Bounds() - invocx, _, _ := cfg.prog.ComputeInvocations() - nx, ny := cfg.div2(bounds) - - pos := ms2.AppendGrid(cfg.posbuf2s[0][:0], bounds, nx, ny) - distCPU := cfg.distbuf[0][:len(pos)] - distGPU := cfg.distbuf[1][:len(pos)] - - // Do CPU evaluation. - sdfcpu, err := gleval.AssertSDF2(obj) - if err != nil { - t.Fatal(err) - } - err = sdfcpu.Evaluate(pos, distCPU, &cfg.vp) - if err != nil { - t.Fatal(err) - } - - // Do GPU evaluation. - cfg.progbuf.Reset() - n, objs, err := cfg.prog.WriteComputeSDF2(&cfg.progbuf, obj) - if err != nil { - t.Fatal(err) - } - if n != cfg.progbuf.Len() { - t.Fatalf("written bytes not match length of buffer %d != %d", n, cfg.progbuf.Len()) - } - sdfgpu, err := gleval.NewComputeGPUSDF2(&cfg.progbuf, bounds, gleval.ComputeConfig{ - InvocX: invocx, - ShaderObjects: objs, - }) - if err != nil { - t.Fatal(err) - } - err = sdfgpu.Evaluate(pos, distGPU, nil) - if err != nil { - t.Fatal(err) - } - err = cmpDist(t, pos, distCPU, distGPU) - if err != nil { - name := appendShaderName(nil, obj) - t.Errorf("%s: %s", name, err) - } -} - -func cmpDist[T any](t *tb, pos []T, dcpu, dgpu []float32) error { - mismatches := 0 - const tol = 5e-3 - var mismatchErr error - for i, dc := range dcpu { - dg := dgpu[i] - diff := math32.Abs(dg - dc) - if diff > tol { - mismatches++ - t.Errorf("mismatch: pos=%+v cpu=%f, gpu=%f (diff=%f) idx=%d", pos[i], dc, dg, diff, i) - if mismatches > 8 { - return errors.New("too many mismatched") - } - } - } - return mismatchErr -} - -type tb struct { - fail bool -} - -func (t *tb) Error(args ...any) { - t.fail = true - log.Print(args...) -} -func (t *tb) Errorf(msg string, args ...any) { - t.fail = true - log.Printf(msg, args...) -} - -func (t *tb) Fatal(args ...any) { - t.fail = true - log.Fatal(args...) -} -func (t *tb) Fatalf(msg string, args ...any) { - t.fail = true - log.Fatalf(msg, args...) -} - -func randomRotation(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { - var axis ms3.Vec - for ms3.Norm(axis) < .5 { - axis = ms3.Vec{X: rng.Float32() * 3, Y: rng.Float32() * 3, Z: rng.Float32() * 3} - } - const maxAngle = 3.14159 - var angle float32 - for math32.Abs(angle) < 1e-1 || math32.Abs(angle) > 1 { - angle = 2 * maxAngle * (rng.Float32() - 0.5) - } - a = bld.Rotate(a, angle, axis) - return a -} - -func randomShell(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { - bb := a.Bounds() - size := bb.Size() - maxSize := bb.Size().Max() / 128 - thickness := math32.Min(maxSize, rng.Float32()) - if thickness <= 1e-8 { - thickness = math32.Min(maxSize, rng.Float32()) - } - shell := bld.Shell(a, thickness) - // Cut shell to visualize interior. - - center := bb.Center() - bb.Max.Y = center.Y - - halfbox := bld.NewBox(size.X*20, size.Y/3, size.Z*20, 0) - halfbox = bld.Translate(halfbox, 0, size.Y/3, 0) - halfbox = bld.Translate(halfbox, 0, size.Y/3, 0) - return bld.Difference(shell, halfbox) -} - -func randomElongate(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { - const minDim = 0.0 - const maxDim = 0.3 - const dim = maxDim - minDim - dx, dy, dz := dim*rng.Float32()+minDim, dim*rng.Float32()+minDim, dim*rng.Float32()+minDim - return bld.Elongate(a, dx, dy, dz) -} - -func randomRound(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { - bb := a.Bounds().Size() - minround := bb.Min() / 64 - maxround := bb.Min() / 2 - round := minround + (rng.Float32() * (maxround - minround)) - return bld.Offset(a, -round) -} - -func randomTranslate(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { - var p ms3.Vec - for ms3.Norm(p) < 0.1 { - p = ms3.Vec{X: rng.Float32(), Y: rng.Float32(), Z: rng.Float32()} - p = ms3.Scale((rng.Float32()-0.5)*4, p) - } - - return bld.Translate(a, p.X, p.Y, p.Z) -} - -func randomSymmetry(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { - q := rng.Uint32() - for q&0b111 == 0 { - q = rng.Uint32() - } - x := q&(1<<0) != 0 - y := q&(1<<1) != 0 - z := q&(1<<2) != 0 - return bld.Symmetry(a, x, y, z) -} - -func randomScale(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { - const minScale, maxScale = 0.01, 3 - scale := minScale + rng.Float32()*(maxScale-minScale) - return bld.Scale(a, scale) -} - -func randomExtrude(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader3D { - const minheight, maxHeight = 0.01, 4. - height := minheight + rng.Float32()*(maxHeight-minheight) - ex := bld.Extrude(a, height) - return ex -} - -func randomRevolve(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader3D { - const minOff, maxOff float32 = 0, 0 - off := minOff + rng.Float32()*(maxOff-minOff) - rev := bld.Revolve(a, off) - return rev -} - -func randomCircArray(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { - circleDiv := rng.Intn(16) + 3 - nInst := rng.Intn(circleDiv) + 1 - s := bld.CircularArray(a, nInst, circleDiv) - return s -} - -func randomCircArray2D(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader2D { - circleDiv := rng.Intn(16) + 3 - nInst := rng.Intn(circleDiv) + 1 - s := bld.CircularArray2D(a, nInst, circleDiv) - return s -} - -func randomAnnulus(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader2D { - s := bld.Annulus(a, rng.Float32()) - return s -} - -func randomArray2D(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader2D { - const minDim = 0.1 - const maxRepeat = 8 - nx, ny := rng.Intn(maxRepeat)+1, rng.Intn(maxRepeat)+1 - dx, dy := rng.Float32()+minDim, rng.Float32()+minDim - s := bld.Array2D(a, dx, dy, nx, ny) - return s -} - -func randomSymmetry2D(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader2D { - q := rng.Uint32() - for q&0b111 == 0 { - q = rng.Uint32() - } - return bld.Symmetry2D(a, q&1 != 0, q&2 != 0) -} - -func randomOffset2D(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader2D { - off := rng.Float32() - 0.5 - return bld.Offset2D(a, off) -} - -func randomRotation2D(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader2D { - angle := (math.Pi*rng.Float32() + 0.001) - return bld.Rotate2D(a, angle) -} - -func randomArray(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { - const minDim = 0.1 - const maxRepeat = 8 - nx, ny, nz := rng.Intn(maxRepeat)+1, rng.Intn(maxRepeat)+1, rng.Intn(maxRepeat)+1 - dx, dy, dz := rng.Float32()+minDim, rng.Float32()+minDim, rng.Float32()+minDim - s := bld.Array(a, dx, dy, dz, nx, ny, nz) - return s -} - -func appendShaderName(name []byte, obj glbuild.Shader) []byte { - var children []glbuild.Shader - if obj3, ok := obj.(glbuild.Shader3D); ok { - obj3.ForEachChild(nil, func(userData any, s *glbuild.Shader3D) error { - children = append(children, *s) - return nil - }) - } else if obj2, ok := obj.(glbuild.Shader2D); ok { - obj2.ForEach2DChild(nil, func(userData any, s *glbuild.Shader2D) error { - children = append(children, *s) - return nil - }) - } else { - panic(fmt.Sprintf("bad object type: %T, with name %s", obj, string(obj.AppendShaderName(nil)))) - } - tpname := reflect.TypeOf(obj).String() - name = append(name, tpname[strings.IndexByte(tpname, '.')+1:]...) - if len(children) > 0 { - name = append(name, '(') - for i := range children { - name = appendShaderName(name, children[i]) - name = append(name, '|') - } - name[len(name)-1] = ')' - } - return name -} - -func TestAppendShaderName(t *testing.T) { - var bld Builder - const want = "translate2D(OpUnion2D(arc2D|arc2D))" - arc := bld.NewArc(1, 1, 0.1) - arc = bld.Union2D(arc, arc) - arc = bld.Translate2D(arc, 0.1, 2) - result := string(appendShaderName(nil, arc)) - if result != want { - t.Errorf("mismatched result got:\n%s\nwant:\n%s", result, want) - } -} - -func getFnName(fnPtr any) string { - name := runtime.FuncForPC(reflect.ValueOf(fnPtr).Pointer()).Name() - idx := strings.LastIndexByte(name, '.') - return name[idx+1:] -} - -func test_bounds(sdf gleval.SDF3, userData any, cfg *shaderTestConfig) (err error) { - const eps = 1e-2 - // Evaluate the - bb := sdf.Bounds() - size := bb.Size() - nx, ny, nz := cfg.div3(bb) - // We create adjacent bounding boxes to the bounding box - // being tested and evaluate the SDF there. We look for following inconsistencies: - // - Negative distance, which implies interior of SDF outside the intended bounding box. - // - Normals which point towards the original bounding box, which imply a SDF surface outside the bounding box. - var offs = [3]float32{-1, 0, 1} - N := nx * ny * nz - - dist := cfg.distbuf[0][:N] - newPos := cfg.posbufs[1][:N] - normals := cfg.posbufs[2][:N] - wantNormals := cfg.posbufs[3][:N] - // Calculate approximate expected normal directions. - wantNormals = ms3.AppendGrid(wantNormals[:0], bb.Add(ms3.Scale(-1, bb.Center())), nx, ny, nz) - - var offsize ms3.Vec - for _, xo := range offs { - offsize.X = xo * (size.X + eps) - for _, yo := range offs { - offsize.Y = yo * (size.Y + eps) - for _, zo := range offs { - offsize.Z = zo * (size.Z + eps) - if xo == 0 && yo == 0 && zo == 0 { - continue // Would perform no change to bounding box. - } - newBB := bb.Add(offsize) - // New mesh lies outside of bounding box. - newPos = ms3.AppendGrid(newPos[:0], newBB, nx, ny, nz) - // Calculate expected normal directions. - - err = sdf.Evaluate(newPos, dist, userData) - if err != nil { - return err - } - for i, d := range dist { - if d < 0 { - return fmt.Errorf("ext bounding box point %v (d=%f) within SDF off=%+v", newPos[i], d, offsize) - } - } - err = gleval.NormalsCentralDiff(sdf, newPos, normals, eps/2, userData) - if err != nil { - return err - } - for i, got := range normals { - want := ms3.Add(offsize, wantNormals[i]) - got = ms3.Unit(got) - angle := ms3.Cos(got, want) - if angle < math32.Sqrt2/2 { - msg := fmt.Sprintf("bad norm angle %frad p=%v got %v, want %v -> off=%v bb=%+v", angle, newPos[i], got, want, offsize, newBB) - if angle <= 0 { - err = errors.New(msg) - return err //errors.New(msg) // Definitely have a surface outside of the bounding box. - } else { - // fmt.Println("WARN bad normal:", msg) // Is this possible with a surface contained within the bounding box? Maybe an ill-conditioned/pointy surface? - } - } - } - } - } - } - return nil + cfg.useGPU = true + err = testGSDF(cfg) + return err } func ui(s glbuild.Shader3D, width, height int) error { diff --git a/gsdf_test.go b/gsdf_test.go index 76150db..201b8ed 100644 --- a/gsdf_test.go +++ b/gsdf_test.go @@ -2,13 +2,79 @@ package gsdf import ( "bytes" + "errors" + "fmt" + "log" "math" + "math/rand" + "reflect" + "runtime" + "strings" "testing" + "github.com/chewxy/math32" + "github.com/soypat/glgl/math/ms1" + "github.com/soypat/glgl/math/ms2" "github.com/soypat/glgl/math/ms3" "github.com/soypat/gsdf/glbuild" + "github.com/soypat/gsdf/gleval" ) +var testGSDFCalled = false + +type shaderTestConfig struct { + bld *Builder + useGPU bool + posbufs [4][]ms3.Vec + posbuf2s [4][]ms2.Vec + distbuf [4][]float32 + testres float32 + vp gleval.VecPool + prog glbuild.Programmer + progbuf bytes.Buffer + rng *rand.Rand + failedObj glbuild.Shader3D +} + +func newShaderTestConfig() *shaderTestConfig { + const bufsize = 32 * 32 * 32 + cfg := &shaderTestConfig{ + testres: 1. / 3, + prog: *glbuild.NewDefaultProgrammer(), + rng: rand.New(rand.NewSource(1)), + bld: &Builder{}, + } + for i := range cfg.posbuf2s { + cfg.posbuf2s[i] = make([]ms2.Vec, bufsize) + cfg.posbufs[i] = make([]ms3.Vec, bufsize) + cfg.distbuf[i] = make([]float32, bufsize) + } + return cfg +} + +func (cfg *shaderTestConfig) div3(bounds ms3.Box) (int, int, int) { + sz := bounds.Size() + nx, ny, nz := cfg.div(sz.X), cfg.div(sz.Y), cfg.div(sz.Z) + return nx, ny, nz +} +func (cfg *shaderTestConfig) div2(bounds ms2.Box) (int, int) { + sz := bounds.Size() + nx, ny := cfg.div(sz.X), cfg.div(sz.Y) + return nx, ny +} +func (cfg *shaderTestConfig) div(dim float32) int { + divs := dim / cfg.testres + return int(ms1.Clamp(divs, 5, 32)) +} + +func TestGSDF(t *testing.T) { + cfg := newShaderTestConfig() + err := testGSDF(cfg) + if err != nil { + t.Error(err) + } +} + func TestTransformDuplicateBug(t *testing.T) { var bld Builder G := bld.NewCircle(1) @@ -54,3 +120,632 @@ func TestTransformDuplicateBug(t *testing.T) { t.Error("mismatched length") } } + +func TestBuilderErrors(t *testing.T) { + var bld Builder + bld.SetFlags(FlagNoDimensionPanic) + s := bld.NewCircle(-1) + if s == nil { + t.Error("expecting non-nil shape") + } + if bld.Err() == nil { + t.Error("expecting error in builder") + } + bld.ClearErrors() + if bld.Err() != nil { + t.Error("expected builder error to be cleared") + } +} + +func testGSDF(cfg *shaderTestConfig) error { + if testGSDFCalled { + return nil + } + testGSDFCalled = true + + t := &tb{} + var tests = []func(*tb, *shaderTestConfig){ + testPrimitives3D, + testPrimitives2D, + testBinOp3D, + testRandomUnary3D, + testBinary2D, + testRandomUnary2D, + } + for _, test := range tests { + test(t, cfg) + if t.fail { + return fmt.Errorf("%s: test failed", getFnName(test)) + } + bldErr := cfg.bld.Err() + if bldErr != nil { + t.Errorf("%s: got Builder error %q", getFnName(test), bldErr.Error()) + cfg.bld.ClearErrors() + } + } + return nil +} + +func testPrimitives3D(t *tb, cfg *shaderTestConfig) { + bld := cfg.bld + const maxdim float32 = 1.0 + dimVec := ms3.Vec{X: maxdim, Y: maxdim * 0.47, Z: maxdim * 0.8} + thick := maxdim / 10 + var primitives = []glbuild.Shader3D{ + bld.NewSphere(1), + bld.NewBox(dimVec.X, dimVec.Y, dimVec.Z, thick), + bld.NewBoxFrame(dimVec.X, dimVec.Y, dimVec.Z, thick), + bld.NewCylinder(dimVec.X, dimVec.Y, 0), + bld.NewCylinder(dimVec.X, dimVec.Y, thick), + bld.NewHexagonalPrism(dimVec.X, dimVec.Y), + bld.NewTorus(dimVec.X, dimVec.Y), + bld.NewTriangularPrism(1, 0.5), + // bld.NewBoundsBoxFrame(ms3.NewBox(0, 0, 0, dimVec.X, dimVec.Y, dimVec.Z)), + } + for _, primitive := range primitives { + testShader3D(t, primitive, cfg) + } +} + +func testBinOp3D(t *tb, cfg *shaderTestConfig) { + bld := cfg.bld + unionBin := func(a, b glbuild.Shader3D) glbuild.Shader3D { + return bld.Union(a, b) + } + var BinaryOps = []func(a, b glbuild.Shader3D) glbuild.Shader3D{ + unionBin, + bld.Difference, + bld.Intersection, + bld.Xor, + } + var smoothOps = []func(k float32, a, b glbuild.Shader3D) glbuild.Shader3D{ + bld.SmoothUnion, + bld.SmoothDifference, + bld.SmoothIntersect, + } + + s1 := bld.NewSphere(1) + s2 := bld.NewBox(1, 0.6, .8, 0.1) + s2 = bld.Translate(s2, 0.5, 0.7, 0.8) + for _, op := range BinaryOps { + result := op(s1, s2) + testShader3D(t, result, cfg) + } + for _, op := range smoothOps { + result := op(0.1, s1, s2) + testShader3D(t, result, cfg) + } +} + +func testRandomUnary2D(t *tb, cfg *shaderTestConfig) { + bld := cfg.bld + obj := bld.NewRectangle(1, 0.61) + obj = bld.Translate2D(obj, 2, .3) + var RandUnary2D = []func(*Builder, glbuild.Shader2D, *rand.Rand) glbuild.Shader2D{ + randomArray2D, // Not sure why does not work. + randomCircArray2D, + randomSymmetry2D, + randomRotation2D, + randomAnnulus, + randomOffset2D, + randomScale2D, + } + for _, op := range RandUnary2D { + for i := 0; i < 10; i++ { + result := op(bld, obj, cfg.rng) + testShader2D(t, result, cfg) + } + } +} + +func testRandomUnary3D(t *tb, cfg *shaderTestConfig) { + bld := cfg.bld + var UnaryRandomizedOps = []func(*Builder, glbuild.Shader3D, *rand.Rand) glbuild.Shader3D{ + randomRotation, + randomShell, + randomElongate, + randomRound, + randomScale, + randomSymmetry, + randomTranslate, + randomArray, + randomCircArray, + } + var OtherUnaryRandomizedOps2D3D = []func(*Builder, glbuild.Shader2D, *rand.Rand) glbuild.Shader3D{ + randomExtrude, + randomRevolve, + } + s2 := bld.NewBox(1, 0.61, 0.8, 0.3) + for _, op := range UnaryRandomizedOps { + result := op(bld, s2, cfg.rng) + testShader3D(t, result, cfg) + } + + s2d := &rect2D{d: ms2.Vec{X: 1, Y: 0.57}} + for _, op := range OtherUnaryRandomizedOps2D3D { + result := op(bld, s2d, cfg.rng) + testShader3D(t, result, cfg) + } +} + +func testPrimitives2D(t *tb, cfg *shaderTestConfig) { + const maxdim float32 = 1.0 + var pbuilder ms2.PolygonBuilder + pbuilder.Nagon(8, 1) + vertices, _ := pbuilder.AppendVecs(nil) + vPrev := vertices[len(vertices)-1] + var segments [][2]ms2.Vec + for i := 0; i < len(vertices); i++ { + segments = append(segments, [2]ms2.Vec{vPrev, vertices[i]}) + vPrev = vertices[i] + } + bld := cfg.bld + dimVec := ms2.Vec{X: maxdim, Y: maxdim * 0.47} + thick := maxdim / 10 + + // Non-SSBO shapes which use dynamic buffers. + poly := bld.NewPolygon(vertices) + polySelfClosed := bld.NewPolygon([]ms2.Vec{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 1, Y: 1}, {X: 0, Y: 0}}) + + // Create shapes to test usage of dynamic buffers as SSBOs. + bld.SetFlags(bld.Flags() | FlagUseShaderBuffers) + + polySSBO := bld.NewPolygon(vertices) + linesSSBO := bld.NewLines2D(segments, 0.1) + displaceSSBO := bld.TranslateMulti2D(poly, vertices) + + bld.SetFlags(bld.Flags() &^ FlagUseShaderBuffers) + + var primitives = []glbuild.Shader2D{ + bld.NewCircle(maxdim), + bld.NewLine2D(0, 0, dimVec.X, dimVec.Y, thick), + bld.NewRectangle(dimVec.X, dimVec.Y), + bld.NewArc(dimVec.X, math.Pi/3, thick), + bld.NewHexagon(maxdim), + bld.NewEquilateralTriangle(maxdim), + bld.NewEllipse(1, 2), // Is incorrect. + poly, + polySelfClosed, + polySSBO, + linesSSBO, + displaceSSBO, + } + for _, primitive := range primitives { + testShader2D(t, primitive, cfg) + } +} + +func testBinary2D(t *tb, cfg *shaderTestConfig) { + bld := cfg.bld + union := func(a, b glbuild.Shader2D) glbuild.Shader2D { + return bld.Union2D(a, b) + } + s2 := bld.NewRectangle(1, 0.61) + s1 := bld.NewCircle(0.4) + s1 = bld.Translate2D(s1, 0.45, 1) + var BinaryOps2D = []func(a, b glbuild.Shader2D) glbuild.Shader2D{ + union, + bld.Difference2D, + bld.Intersection2D, + bld.Xor2D, + } + for _, op := range BinaryOps2D { + result := op(s1, s2) + testShader2D(t, result, cfg) + } +} + +func testShader3D(t *tb, obj glbuild.Shader3D, cfg *shaderTestConfig) { + bld := cfg.bld + vp := &cfg.vp + bounds := obj.Bounds() + invocx, _, _ := cfg.prog.ComputeInvocations() + nx, ny, nz := cfg.div3(bounds) + + pos := ms3.AppendGrid(cfg.posbufs[0][:0], bounds, nx, ny, nz) + distCPU := cfg.distbuf[0][:len(pos)] + distGPU := cfg.distbuf[1][:len(pos)] + + // Do CPU evaluation. + sdfcpu, err := gleval.AssertSDF3(obj) + if err != nil { + t.Fatal(err) + } + err = test_bounds(sdfcpu, vp, cfg) + if err != nil { + bf := bld.NewBoundsBoxFrame(obj.Bounds()) + obj = bld.Union(obj, bf) + name := appendShaderName(nil, obj) + t.Errorf("%s: %s", name, err) + cfg.failedObj = obj + } + + cfg.progbuf.Reset() + n, objs, err := cfg.prog.WriteComputeSDF3(&cfg.progbuf, obj) + if err != nil { + t.Fatal(err) + } + if n != cfg.progbuf.Len() { + t.Fatalf("written bytes not match length of buffer %d != %d", n, cfg.progbuf.Len()) + } + if !cfg.useGPU { + return // No GPU usage permitted, nothing else to do. + } + // Get CPU positional evaluations. + err = sdfcpu.Evaluate(pos, distCPU, vp) + if err != nil { + t.Fatal(err) + } + + // Do GPU evaluation. + sdfgpu, err := gleval.NewComputeGPUSDF3(&cfg.progbuf, bounds, gleval.ComputeConfig{ + InvocX: invocx, + ShaderObjects: objs, + }) + if err != nil { + t.Fatal(err) + } + err = sdfgpu.Evaluate(pos, distGPU, nil) + if err != nil { + t.Fatal(err) + } + err = cmpDist(t, pos, distCPU, distGPU) + if err != nil { + name := appendShaderName(nil, obj) + t.Errorf("%s: %s", name, err) + } +} + +func testShader2D(t *tb, obj glbuild.Shader2D, cfg *shaderTestConfig) { + bounds := obj.Bounds() + invocx, _, _ := cfg.prog.ComputeInvocations() + nx, ny := cfg.div2(bounds) + + pos := ms2.AppendGrid(cfg.posbuf2s[0][:0], bounds, nx, ny) + distCPU := cfg.distbuf[0][:len(pos)] + distGPU := cfg.distbuf[1][:len(pos)] + + // Do CPU evaluation. + sdfcpu, err := gleval.AssertSDF2(obj) + if err != nil { + t.Fatal(err) + } + // Do GPU evaluation. + cfg.progbuf.Reset() + n, objs, err := cfg.prog.WriteComputeSDF2(&cfg.progbuf, obj) + if err != nil { + t.Fatal(err) + } + if n != cfg.progbuf.Len() { + t.Fatalf("written bytes not match length of buffer %d != %d", n, cfg.progbuf.Len()) + } + if !cfg.useGPU { + return // No GPU usage permitted, end run here. + } + + err = sdfcpu.Evaluate(pos, distCPU, &cfg.vp) + if err != nil { + t.Fatal(err) + } + sdfgpu, err := gleval.NewComputeGPUSDF2(&cfg.progbuf, bounds, gleval.ComputeConfig{ + InvocX: invocx, + ShaderObjects: objs, + }) + if err != nil { + t.Fatal(err) + } + err = sdfgpu.Evaluate(pos, distGPU, nil) + if err != nil { + t.Fatal(err) + } + err = cmpDist(t, pos, distCPU, distGPU) + if err != nil { + name := appendShaderName(nil, obj) + t.Errorf("%s: %s", name, err) + } +} + +func cmpDist[T any](t *tb, pos []T, dcpu, dgpu []float32) error { + mismatches := 0 + const tol = 5e-3 + var mismatchErr error + for i, dc := range dcpu { + dg := dgpu[i] + diff := math32.Abs(dg - dc) + if diff > tol { + mismatches++ + t.Errorf("mismatch: pos=%+v cpu=%f, gpu=%f (diff=%f) idx=%d", pos[i], dc, dg, diff, i) + if mismatches > 8 { + return errors.New("too many mismatched") + } + } + } + return mismatchErr +} + +type tb struct { + fail bool +} + +func (t *tb) Error(args ...any) { + t.fail = true + log.Print(args...) +} +func (t *tb) Errorf(msg string, args ...any) { + t.fail = true + log.Printf(msg, args...) +} + +func (t *tb) Fatal(args ...any) { + t.fail = true + log.Fatal(args...) +} +func (t *tb) Fatalf(msg string, args ...any) { + t.fail = true + log.Fatalf(msg, args...) +} + +func randomRotation(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { + var axis ms3.Vec + for ms3.Norm(axis) < .5 { + axis = ms3.Vec{X: rng.Float32() * 3, Y: rng.Float32() * 3, Z: rng.Float32() * 3} + } + const maxAngle = 3.14159 + var angle float32 + for math32.Abs(angle) < 1e-1 || math32.Abs(angle) > 1 { + angle = 2 * maxAngle * (rng.Float32() - 0.5) + } + a = bld.Rotate(a, angle, axis) + return a +} + +func randomShell(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { + bb := a.Bounds() + size := bb.Size() + maxSize := bb.Size().Max() / 128 + thickness := math32.Min(maxSize, rng.Float32()) + if thickness <= 1e-8 { + thickness = math32.Min(maxSize, rng.Float32()) + } + shell := bld.Shell(a, thickness) + // Cut shell to visualize interior. + + center := bb.Center() + bb.Max.Y = center.Y + + halfbox := bld.NewBox(size.X*20, size.Y/3, size.Z*20, 0) + halfbox = bld.Translate(halfbox, 0, size.Y/3, 0) + halfbox = bld.Translate(halfbox, 0, size.Y/3, 0) + return bld.Difference(shell, halfbox) +} + +func randomElongate(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { + const minDim = 0.0 + const maxDim = 0.3 + const dim = maxDim - minDim + dx, dy, dz := dim*rng.Float32()+minDim, dim*rng.Float32()+minDim, dim*rng.Float32()+minDim + return bld.Elongate(a, dx, dy, dz) +} + +func randomRound(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { + bb := a.Bounds().Size() + minround := bb.Min() / 64 + maxround := bb.Min() / 2 + round := minround + (rng.Float32() * (maxround - minround)) + return bld.Offset(a, -round) +} + +func randomTranslate(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { + var p ms3.Vec + for ms3.Norm(p) < 0.1 { + p = ms3.Vec{X: rng.Float32(), Y: rng.Float32(), Z: rng.Float32()} + p = ms3.Scale((rng.Float32()-0.5)*4, p) + } + + return bld.Translate(a, p.X, p.Y, p.Z) +} + +func randomSymmetry(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { + q := rng.Uint32() + for q&0b111 == 0 { + q = rng.Uint32() + } + x := q&(1<<0) != 0 + y := q&(1<<1) != 0 + z := q&(1<<2) != 0 + return bld.Symmetry(a, x, y, z) +} + +func randomScale(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { + const minScale, maxScale = 0.01, 3 + scale := minScale + rng.Float32()*(maxScale-minScale) + return bld.Scale(a, scale) +} + +func randomExtrude(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader3D { + const minheight, maxHeight = 0.01, 4. + height := minheight + rng.Float32()*(maxHeight-minheight) + ex := bld.Extrude(a, height) + return ex +} + +func randomRevolve(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader3D { + const minOff, maxOff float32 = 0, 0 + off := minOff + rng.Float32()*(maxOff-minOff) + rev := bld.Revolve(a, off) + return rev +} + +func randomCircArray(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { + circleDiv := rng.Intn(16) + 3 + nInst := rng.Intn(circleDiv) + 1 + s := bld.CircularArray(a, nInst, circleDiv) + return s +} + +func randomCircArray2D(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader2D { + circleDiv := rng.Intn(16) + 3 + nInst := rng.Intn(circleDiv) + 1 + s := bld.CircularArray2D(a, nInst, circleDiv) + return s +} + +func randomAnnulus(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader2D { + s := bld.Annulus(a, rng.Float32()) + return s +} + +func randomScale2D(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader2D { + s := bld.Scale2D(a, rng.Float32()) + return s +} + +func randomArray2D(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader2D { + const minDim = 0.1 + const maxRepeat = 8 + nx, ny := rng.Intn(maxRepeat)+1, rng.Intn(maxRepeat)+1 + dx, dy := rng.Float32()+minDim, rng.Float32()+minDim + s := bld.Array2D(a, dx, dy, nx, ny) + return s +} + +func randomSymmetry2D(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader2D { + q := rng.Uint32() + for q&0b111 == 0 { + q = rng.Uint32() + } + return bld.Symmetry2D(a, q&1 != 0, q&2 != 0) +} + +func randomOffset2D(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader2D { + off := rng.Float32() - 0.5 + return bld.Offset2D(a, off) +} + +func randomRotation2D(bld *Builder, a glbuild.Shader2D, rng *rand.Rand) glbuild.Shader2D { + angle := (math.Pi*rng.Float32() + 0.001) + return bld.Rotate2D(a, angle) +} + +func randomArray(bld *Builder, a glbuild.Shader3D, rng *rand.Rand) glbuild.Shader3D { + const minDim = 0.1 + const maxRepeat = 8 + nx, ny, nz := rng.Intn(maxRepeat)+1, rng.Intn(maxRepeat)+1, rng.Intn(maxRepeat)+1 + dx, dy, dz := rng.Float32()+minDim, rng.Float32()+minDim, rng.Float32()+minDim + s := bld.Array(a, dx, dy, dz, nx, ny, nz) + return s +} + +func appendShaderName(name []byte, obj glbuild.Shader) []byte { + var children []glbuild.Shader + if obj3, ok := obj.(glbuild.Shader3D); ok { + obj3.ForEachChild(nil, func(userData any, s *glbuild.Shader3D) error { + children = append(children, *s) + return nil + }) + } else if obj2, ok := obj.(glbuild.Shader2D); ok { + obj2.ForEach2DChild(nil, func(userData any, s *glbuild.Shader2D) error { + children = append(children, *s) + return nil + }) + } else { + panic(fmt.Sprintf("bad object type: %T, with name %s", obj, string(obj.AppendShaderName(nil)))) + } + tpname := reflect.TypeOf(obj).String() + name = append(name, tpname[strings.IndexByte(tpname, '.')+1:]...) + if len(children) > 0 { + name = append(name, '(') + for i := range children { + name = appendShaderName(name, children[i]) + name = append(name, '|') + } + name[len(name)-1] = ')' + } + return name +} + +func TestAppendShaderName(t *testing.T) { + var bld Builder + const want = "translate2D(OpUnion2D(arc2D|arc2D))" + arc := bld.NewArc(1, 1, 0.1) + arc = bld.Union2D(arc, arc) + arc = bld.Translate2D(arc, 0.1, 2) + result := string(appendShaderName(nil, arc)) + if result != want { + t.Errorf("mismatched result got:\n%s\nwant:\n%s", result, want) + } +} + +func getFnName(fnPtr any) string { + name := runtime.FuncForPC(reflect.ValueOf(fnPtr).Pointer()).Name() + idx := strings.LastIndexByte(name, '.') + return name[idx+1:] +} + +func test_bounds(sdf gleval.SDF3, userData any, cfg *shaderTestConfig) (err error) { + const eps = 1e-2 + // Evaluate the + bb := sdf.Bounds() + size := bb.Size() + nx, ny, nz := cfg.div3(bb) + // We create adjacent bounding boxes to the bounding box + // being tested and evaluate the SDF there. We look for following inconsistencies: + // - Negative distance, which implies interior of SDF outside the intended bounding box. + // - Normals which point towards the original bounding box, which imply a SDF surface outside the bounding box. + var offs = [3]float32{-1, 0, 1} + N := nx * ny * nz + + dist := cfg.distbuf[0][:N] + newPos := cfg.posbufs[1][:N] + normals := cfg.posbufs[2][:N] + wantNormals := cfg.posbufs[3][:N] + // Calculate approximate expected normal directions. + wantNormals = ms3.AppendGrid(wantNormals[:0], bb.Add(ms3.Scale(-1, bb.Center())), nx, ny, nz) + + var offsize ms3.Vec + for _, xo := range offs { + offsize.X = xo * (size.X + eps) + for _, yo := range offs { + offsize.Y = yo * (size.Y + eps) + for _, zo := range offs { + offsize.Z = zo * (size.Z + eps) + if xo == 0 && yo == 0 && zo == 0 { + continue // Would perform no change to bounding box. + } + newBB := bb.Add(offsize) + // New mesh lies outside of bounding box. + newPos = ms3.AppendGrid(newPos[:0], newBB, nx, ny, nz) + // Calculate expected normal directions. + + err = sdf.Evaluate(newPos, dist, userData) + if err != nil { + return err + } + for i, d := range dist { + if d < 0 { + return fmt.Errorf("ext bounding box point %v (d=%f) within SDF off=%+v", newPos[i], d, offsize) + } + } + err = gleval.NormalsCentralDiff(sdf, newPos, normals, eps/2, userData) + if err != nil { + return err + } + for i, got := range normals { + want := ms3.Add(offsize, wantNormals[i]) + got = ms3.Unit(got) + angle := ms3.Cos(got, want) + if angle < math32.Sqrt2/2 { + msg := fmt.Sprintf("bad norm angle %frad p=%v got %v, want %v -> off=%v bb=%+v", angle, newPos[i], got, want, offsize, newBB) + if angle <= 0 { + err = errors.New(msg) + return err //errors.New(msg) // Definitely have a surface outside of the bounding box. + } else { + // fmt.Println("WARN bad normal:", msg) // Is this possible with a surface contained within the bounding box? Maybe an ill-conditioned/pointy surface? + } + } + } + } + } + } + return nil +}