Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ func diagnosticHint(code, message string) string {
return "Move the derivative call into surface(); a vertex shader has no neighboring screen-space pixels to derive against."
case strings.Contains(message, "is not available in the vertex stage"):
return "Sample the texture in surface() instead, or read live simulation state in vertex() with stateAt(uv)."
case strings.Contains(message, "which also declares vertex()"):
return "Remove one of the two vertex() declarations, or drop `extends` if the child needs its own vertex stage."
}
switch code {
case "SEL0001":
Expand Down
36 changes: 33 additions & 3 deletions lower/extends.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import (
)

// resolveExtends flattens m's `extends` chain: it merges the parent's params
// (child overrides by name) and inlines super.surface(geo) into the child's
// surface using the parent's (recursively flattened) surface. all is the set of
// materials parents resolve against.
// (child overrides by name), inherits the parent's vertex stage (with the
// varyings and statefield it goes with) when the child declares none of its
// own, and inlines super.surface(geo) into the child's surface using the
// parent's (recursively flattened) surface. all is the set of materials
// parents resolve against.
func resolveExtends(m hir.Material, all []hir.Material) (hir.Material, error) {
if m.Extends == "" {
return m, nil
Expand All @@ -29,6 +31,34 @@ func resolveExtends(m hir.Material, all []hir.Material) (hir.Material, error) {
out.Extends = ""
out.Params = mergeParams(parent.Params, m.Params)

// A parent's vertex() stage — and the varyings/statefield it writes and
// reads — used to be dropped here with no diagnostic: out := m above
// already carries the CHILD's own Vertex/Varyings/States (nil/empty when
// the child declares none), and nothing below ever consulted the parent's.
// A material that extended a `Ripple`-style vertex-authoring parent lost
// its geometry silently while surface() composition kept working, so the
// compile reported success and emitted the parent's default (undisplaced)
// transform instead. Inherit instead: when the child declares no vertex
// stage, it takes the parent's, complete with the parent's varyings and
// statefield declaration, so surface() (including an inlined
// super.surface(geo)) keeps resolving the geo fields the parent stage
// produces. A child that declares its own vertex() while extending a
// parent that also declares one is rejected — silently preferring either
// side would just move the silent drop, and super.vertex() composition
// (like control-flow parents in super.surface, see inline.go) is not
// supported yet.
if parent.Vertex != nil {
if m.Vertex != nil {
return m, diagnostic(CodeUnsupportedFeat, m.Span,
"material %q declares vertex() and extends %q, which also declares vertex(); a child material cannot override or merge a parent's vertex stage",
m.Name, m.Extends,
)
}
out.Vertex = parent.Vertex
out.Varyings = parent.Varyings
out.States = parent.States
}

in := &inliner{parent: &parent.Surface}
surf := m.Surface
body, err := in.stmts(m.Surface.Body, nil)
Expand Down
167 changes: 167 additions & 0 deletions lower/extends_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package lower

import (
"errors"
"strings"
"testing"

"m31labs.dev/selena/emit/wgsl"
"m31labs.dev/selena/parse"
)

// TestResolveExtendsInheritsParentVertexStage pins Defect 2's fix: a child
// that extends a parent authoring vertex()/varying/state used to lose all
// three silently. resolveExtends only ever merged Params and inlined
// super.surface; `out := m` already carried the CHILD's own (nil/empty)
// Vertex/Varyings/States, and nothing copied the parent's over, so the
// compile reported success and the emitted shader silently reverted to the
// default (undisplaced) transform. A child that declares no vertex stage of
// its own now inherits the parent's vertex(), its varyings, and its
// statefield, so the parent's geometry work and stateAt(uv) read survive
// `extends` and super.surface keeps resolving the geo fields the inherited
// vertex stage produces.
func TestResolveExtendsInheritsParentVertexStage(t *testing.T) {
src := `material RippleBase {
param amp : float = 0.2

state height

varying worldPos : vec3

vertex() -> vec4 {
let fi = float(vertexIndex)
let h = stateAt(vec2f(0.0, 0.0)).x * amp
let p = vec3f(fi, h, 0.0)
worldPos = p
return mvp * vec4f(p, 1.0)
}

surface(geo) -> color {
return rgb(geo.worldPos.x, geo.worldPos.y, geo.worldPos.z)
}
}

material RippleTinted extends RippleBase {
param tint : color = rgb(1.0, 0.8, 0.7)

surface(geo) -> color {
return super.surface(geo) * tint
}
}`
program, err := parse.Program([]byte(src))
if err != nil {
t.Fatal(err)
}
mod, layout, err := LowerProgram(program, 1)
if err != nil {
t.Fatalf("RippleTinted should inherit RippleBase's vertex stage: %v", err)
}
if !mod.VertexAuthored {
t.Fatal("VertexAuthored = false, want true (inherited vertex() stage)")
}
if !mod.UsesVertexIndex {
t.Fatal("UsesVertexIndex = false, want true (inherited procedural geometry)")
}
if len(mod.Varyings) != 1 || mod.Varyings[0].Name != "worldPos" {
t.Fatalf("varyings = %+v, want inherited [worldPos]", mod.Varyings)
}
if mod.StateField != "height" {
t.Fatalf("StateField = %q, want inherited \"height\"", mod.StateField)
}
if len(layout.States) != 1 || layout.States[0].Name != "height" {
t.Fatalf("layout.States = %+v, want inherited [height]", layout.States)
}

src2, err := wgsl.Emit(mod)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(src2, "out.worldPos = p;") {
t.Errorf("WGSL missing the inherited varying write:\n%s", src2)
}
if !strings.Contains(src2, "let h = (") || !strings.Contains(src2, "u.amp)") {
t.Errorf("WGSL missing the inherited stateAt/amp displacement:\n%s", src2)
}
if !strings.Contains(src2, "return vec4<f32>((vec3<f32>(in.worldPos.x, in.worldPos.y, in.worldPos.z) * u.tint), 1.0);") {
t.Errorf("WGSL fragment missing the child's super.surface(geo) * tint composition, reading the inherited varying:\n%s", src2)
}
}

// TestResolveExtendsRejectsChildAndParentBothDeclaringVertex checks the other
// half of Defect 2: silently preferring either side when both a parent and a
// child declare a vertex() stage would just relocate the silent drop, so the
// combination is rejected with a diagnostic instead — mirroring the existing
// restriction on a super.surface parent with control flow (inline.go).
func TestResolveExtendsRejectsChildAndParentBothDeclaringVertex(t *testing.T) {
src := `material BaseVertex {
vertex() -> vec4 {
return vec4f(0.0, 0.0, 0.0, 1.0)
}
surface(geo) -> color {
return rgb(1.0, 1.0, 1.0)
}
}

material ChildVertex extends BaseVertex {
vertex() -> vec4 {
return vec4f(1.0, 1.0, 1.0, 1.0)
}
surface(geo) -> color {
return rgb(0.0, 0.0, 0.0)
}
}`
program, err := parse.Program([]byte(src))
if err != nil {
t.Fatal(err)
}
_, _, err = LowerProgram(program, 1)
if err == nil {
t.Fatal("child and parent both declaring vertex() lowered, want a diagnostic")
}
var de *DiagnosticError
if !errors.As(err, &de) {
t.Fatalf("error type = %T, want *DiagnosticError", err)
}
if de.Code != CodeUnsupportedFeat {
t.Fatalf("code = %s, want %s", de.Code, CodeUnsupportedFeat)
}
want := `material "ChildVertex" declares vertex() and extends "BaseVertex", which also declares vertex(); a child material cannot override or merge a parent's vertex stage`
if de.Message != want {
t.Fatalf("message = %q, want %q", de.Message, want)
}
}

// TestResolveExtendsLeavesSurfaceOnlyCompositionUnchanged is a regression
// guard for the common case (neither material declares a vertex stage): the
// new inheritance branch must not fire, and the default-transform mesh path
// (VertexAuthored = false) must still apply, matching testdata/conformance/
// extends.sel's shape.
func TestResolveExtendsLeavesSurfaceOnlyCompositionUnchanged(t *testing.T) {
src := `material Base {
param baseColor : color
surface(geo) -> color {
return baseColor
}
}

material Tinted extends Base {
param tint : color = rgb(1.0, 0.8, 0.7)
surface(geo) -> color {
return super.surface(geo) * tint
}
}`
program, err := parse.Program([]byte(src))
if err != nil {
t.Fatal(err)
}
mod, _, err := LowerProgram(program, 1)
if err != nil {
t.Fatal(err)
}
if mod.VertexAuthored {
t.Fatal("VertexAuthored = true, want false (neither material declares vertex())")
}
if len(mod.Varyings) != 0 {
t.Fatalf("varyings = %+v, want none", mod.Varyings)
}
}
Loading