Skip to content

Commit f4f2f36

Browse files
authored
fix(msl): pass-through globals for helper functions
MSL helper functions cannot access entry point resource bindings (textures, samplers, uniforms, storage buffers) directly. This adds analysis to detect which globals each function references and passes them as extra parameters at declaration and extra arguments at call sites, matching the Rust naga reference implementation. Fixes gogpu/ui#23 (black screen on M3 Mac due to undeclared msdf_atlas/msdf_sampler in MSDF text shader helper functions).
1 parent de99217 commit f4f2f36

File tree

5 files changed

+329
-3
lines changed

5 files changed

+329
-3
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.14.6] - 2026-03-06
9+
10+
### Fixed
11+
12+
#### MSL Backend
13+
- **Pass-through globals for helper functions** — textures, samplers, uniforms, and storage buffers used by non-entry-point functions are now passed as extra parameters and arguments; previously MSL helper functions could not access entry point resource bindings, causing `undeclared identifier` errors (e.g., `msdf_atlas`, `msdf_sampler`, `sh_scratch`) for any shader with helper functions referencing global resources ([gogpu/ui#23](https://github.com/gogpu/ui/issues/23))
14+
815
## [0.14.5] - 2026-03-04
916

1017
### Fixed

msl/backend_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,3 +548,168 @@ func TestCompile_EntryPointReturnAttributePlacement(t *testing.T) {
548548
t.Error("[[position]] should be on struct member, not on function signature")
549549
}
550550
}
551+
552+
// TestMSL_PassThroughGlobals verifies that helper functions receiving
553+
// texture/sampler globals get them as extra parameters, and call sites
554+
// pass them as extra arguments. This is the fix for gogpu/ui#23 where
555+
// MSL helper functions couldn't access entry point resource bindings.
556+
func TestMSL_PassThroughGlobals(t *testing.T) {
557+
// Type handles:
558+
// 0: f32, 1: vec2f, 2: vec4f, 3: texture2d, 4: sampler
559+
tF32 := ir.TypeHandle(0)
560+
tVec2 := ir.TypeHandle(1)
561+
tVec4 := ir.TypeHandle(2)
562+
tTex := ir.TypeHandle(3)
563+
tSamp := ir.TypeHandle(4)
564+
565+
binding0 := &ir.ResourceBinding{Group: 0, Binding: 0}
566+
binding1 := &ir.ResourceBinding{Group: 0, Binding: 1}
567+
568+
module := &ir.Module{
569+
Types: []ir.Type{
570+
{Name: "f32", Inner: ir.ScalarType{Kind: ir.ScalarFloat, Width: 4}},
571+
{Name: "vec2f", Inner: ir.VectorType{Size: ir.Vec2, Scalar: ir.ScalarType{Kind: ir.ScalarFloat, Width: 4}}},
572+
{Name: "vec4f", Inner: ir.VectorType{Size: ir.Vec4, Scalar: ir.ScalarType{Kind: ir.ScalarFloat, Width: 4}}},
573+
{Name: "tex2d", Inner: ir.ImageType{Dim: ir.Dim2D, Class: ir.ImageClassSampled}},
574+
{Name: "samp", Inner: ir.SamplerType{Comparison: false}},
575+
},
576+
GlobalVariables: []ir.GlobalVariable{
577+
{Name: "my_texture", Space: ir.SpaceHandle, Type: tTex, Binding: binding0},
578+
{Name: "my_sampler", Space: ir.SpaceHandle, Type: tSamp, Binding: binding1},
579+
},
580+
Functions: []ir.Function{
581+
{
582+
// Helper function: sample_tex(uv: vec2f) -> vec4f
583+
// References globals: my_texture (0), my_sampler (1)
584+
Name: "sample_tex",
585+
Arguments: []ir.FunctionArgument{
586+
{Name: "uv", Type: tVec2},
587+
},
588+
Result: &ir.FunctionResult{Type: tVec4},
589+
Expressions: []ir.Expression{
590+
{Kind: ir.ExprFunctionArgument{Index: 0}}, // 0: uv
591+
{Kind: ir.ExprGlobalVariable{Variable: 0}}, // 1: my_texture
592+
{Kind: ir.ExprGlobalVariable{Variable: 1}}, // 2: my_sampler
593+
{Kind: ir.ExprImageSample{ // 3: textureSample
594+
Image: 1, Sampler: 2, Coordinate: 0,
595+
Level: ir.SampleLevelAuto{},
596+
}},
597+
},
598+
ExpressionTypes: []ir.TypeResolution{
599+
{Handle: &tVec2}, // 0: uv
600+
{Handle: &tTex}, // 1: texture
601+
{Handle: &tSamp}, // 2: sampler
602+
{Handle: &tVec4}, // 3: sample result
603+
},
604+
Body: []ir.Statement{
605+
{Kind: ir.StmtEmit{Range: ir.Range{Start: 0, End: 4}}},
606+
{Kind: ir.StmtReturn{Value: ptrExpr(3)}},
607+
},
608+
},
609+
{
610+
// Entry point function: calls sample_tex with hardcoded UV
611+
Name: "fs_entry",
612+
Result: &ir.FunctionResult{
613+
Type: tVec4,
614+
Binding: bindingPtr(ir.LocationBinding{Location: 0}),
615+
},
616+
Expressions: []ir.Expression{
617+
{Kind: ir.Literal{Value: ir.LiteralF32(0.5)}}, // 0: 0.5
618+
{Kind: ir.Literal{Value: ir.LiteralF32(0.5)}}, // 1: 0.5
619+
{Kind: ir.ExprCompose{ // 2: vec2(0.5, 0.5)
620+
Type: tVec2, Components: []ir.ExpressionHandle{0, 1},
621+
}},
622+
{Kind: ir.ExprCallResult{Function: 0}}, // 3: result of call
623+
},
624+
ExpressionTypes: []ir.TypeResolution{
625+
{Handle: &tF32}, // 0: literal
626+
{Handle: &tF32}, // 1: literal
627+
{Handle: &tVec2}, // 2: compose
628+
{Handle: &tVec4}, // 3: call result
629+
},
630+
Body: []ir.Statement{
631+
{Kind: ir.StmtEmit{Range: ir.Range{Start: 0, End: 3}}},
632+
{Kind: ir.StmtCall{
633+
Function: 0, // call sample_tex
634+
Arguments: []ir.ExpressionHandle{2},
635+
Result: ptrExpr(3),
636+
}},
637+
{Kind: ir.StmtReturn{Value: ptrExpr(3)}},
638+
},
639+
},
640+
},
641+
EntryPoints: []ir.EntryPoint{
642+
{
643+
Name: "fs_main", Stage: ir.StageFragment,
644+
Function: 1, // references Functions[1]
645+
},
646+
},
647+
}
648+
649+
result, _, err := Compile(module, DefaultOptions())
650+
if err != nil {
651+
t.Fatalf("Compile failed: %v", err)
652+
}
653+
654+
// Helper function should have texture and sampler as extra params (no [[binding]])
655+
if !strings.Contains(result, "sample_tex(") {
656+
t.Fatal("Expected helper function sample_tex in output")
657+
}
658+
659+
// Check that helper function has pass-through params
660+
if !strings.Contains(result, "my_texture") {
661+
t.Error("Expected my_texture in helper function params")
662+
}
663+
if !strings.Contains(result, "my_sampler") {
664+
t.Error("Expected my_sampler in helper function params")
665+
}
666+
667+
// Helper function params should NOT have [[texture(N)]] or [[sampler(N)]] attributes
668+
// (those belong only on entry point params)
669+
lines := strings.Split(result, "\n")
670+
inHelper := false
671+
for _, line := range lines {
672+
if strings.Contains(line, "sample_tex(") {
673+
inHelper = true
674+
}
675+
if inHelper && strings.Contains(line, ") {") {
676+
inHelper = false
677+
}
678+
if inHelper {
679+
if strings.Contains(line, "[[texture(") || strings.Contains(line, "[[sampler(") {
680+
t.Errorf("Helper function should not have binding attributes, got: %s", line)
681+
}
682+
}
683+
}
684+
685+
// Entry point should have [[texture(0)]] and [[sampler(1)]]
686+
if !strings.Contains(result, "[[texture(0)]]") {
687+
t.Error("Expected [[texture(0)]] on entry point param")
688+
}
689+
if !strings.Contains(result, "[[sampler(1)]]") {
690+
t.Error("Expected [[sampler(1)]] on entry point param")
691+
}
692+
693+
// Call site should pass globals as extra arguments
694+
// Look for something like: sample_tex(vec2f_expr, my_texture, my_sampler)
695+
foundCallWithGlobals := false
696+
for _, line := range lines {
697+
if strings.Contains(line, "sample_tex(") && strings.Contains(line, "my_texture") && strings.Contains(line, "my_sampler") {
698+
foundCallWithGlobals = true
699+
break
700+
}
701+
}
702+
if !foundCallWithGlobals {
703+
t.Error("Expected call site to pass my_texture and my_sampler as extra arguments")
704+
}
705+
706+
t.Logf("Generated MSL:\n%s", result)
707+
}
708+
709+
func ptrExpr(h ir.ExpressionHandle) *ir.ExpressionHandle {
710+
return &h
711+
}
712+
713+
func bindingPtr(b ir.Binding) *ir.Binding {
714+
return &b
715+
}

msl/functions.go

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,98 @@ const (
1313
spaceDevice = "device"
1414
)
1515

16+
// needsPassThrough returns true if global variables in this address space
17+
// must be passed as function arguments in MSL. MSL doesn't have true globals
18+
// for resources — they must be passed through from entry points.
19+
func needsPassThrough(space ir.AddressSpace) bool {
20+
switch space {
21+
case ir.SpaceUniform, ir.SpaceStorage, ir.SpaceHandle, ir.SpacePrivate, ir.SpaceWorkGroup:
22+
return true
23+
default:
24+
return false
25+
}
26+
}
27+
28+
// analyzeFuncPassThroughGlobals scans each non-entry-point function to determine
29+
// which global variables it references. These globals must be added as extra
30+
// parameters since MSL helper functions cannot access entry point bindings.
31+
// Uses memoization to handle transitive calls (A calls B which uses globals).
32+
func (w *Writer) analyzeFuncPassThroughGlobals() {
33+
for handle := range w.module.Functions {
34+
w.getPassThroughGlobals(ir.FunctionHandle(handle))
35+
}
36+
}
37+
38+
// getPassThroughGlobals returns (and caches) the list of global variable handles
39+
// that a function needs as pass-through parameters.
40+
func (w *Writer) getPassThroughGlobals(handle ir.FunctionHandle) []uint32 {
41+
if cached, ok := w.funcPassThroughGlobals[handle]; ok {
42+
return cached
43+
}
44+
45+
// Mark as visited (empty slice) to prevent infinite recursion
46+
w.funcPassThroughGlobals[handle] = []uint32{}
47+
48+
fn := &w.module.Functions[handle]
49+
seen := make(map[uint32]struct{})
50+
var result []uint32
51+
52+
addGlobal := func(h uint32) {
53+
if _, already := seen[h]; already {
54+
return
55+
}
56+
if int(h) < len(w.module.GlobalVariables) {
57+
gvar := &w.module.GlobalVariables[h]
58+
if needsPassThrough(gvar.Space) {
59+
seen[h] = struct{}{}
60+
result = append(result, h)
61+
}
62+
}
63+
}
64+
65+
// Direct global variable references in expressions
66+
for _, expr := range fn.Expressions {
67+
if gv, ok := expr.Kind.(ir.ExprGlobalVariable); ok {
68+
addGlobal(uint32(gv.Variable))
69+
}
70+
}
71+
72+
// Transitive: globals used by called functions
73+
w.walkStmts(fn.Body, func(call ir.StmtCall) {
74+
if int(call.Function) < len(w.module.Functions) {
75+
for _, h := range w.getPassThroughGlobals(call.Function) {
76+
addGlobal(h)
77+
}
78+
}
79+
})
80+
81+
w.funcPassThroughGlobals[handle] = result
82+
return result
83+
}
84+
85+
// walkStmts walks all statements (including nested blocks) and calls
86+
// the visitor for each StmtCall found.
87+
func (w *Writer) walkStmts(stmts ir.Block, visitCall func(ir.StmtCall)) {
88+
for _, stmt := range stmts {
89+
switch s := stmt.Kind.(type) {
90+
case ir.StmtCall:
91+
visitCall(s)
92+
case ir.StmtBlock:
93+
w.walkStmts(s.Block, visitCall)
94+
case ir.StmtIf:
95+
w.walkStmts(s.Accept, visitCall)
96+
w.walkStmts(s.Reject, visitCall)
97+
case ir.StmtSwitch:
98+
for _, c := range s.Cases {
99+
w.walkStmts(c.Body, visitCall)
100+
}
101+
case ir.StmtLoop:
102+
w.walkStmts(s.Body, visitCall)
103+
w.walkStmts(s.Continuing, visitCall)
104+
}
105+
}
106+
}
107+
16108
// writeFunctions writes all non-entry-point function definitions.
17109
func (w *Writer) writeFunctions() error {
18110
for handle := range w.module.Functions {
@@ -68,13 +160,26 @@ func (w *Writer) writeFunction(handle ir.FunctionHandle, fn *ir.Function) error
68160
w.write("%s %s(", returnType, funcName)
69161

70162
// Parameters
163+
paramCount := 0
71164
for i, arg := range fn.Arguments {
72-
if i > 0 {
165+
if paramCount > 0 {
73166
w.write(", ")
74167
}
75168
argName := w.getName(nameKey{kind: nameKeyFunctionArgument, handle1: uint32(handle), handle2: uint32(i)})
76169
argType := w.writeTypeName(arg.Type, StorageAccess(0))
77170
w.write("%s %s", argType, argName)
171+
paramCount++
172+
}
173+
174+
// Pass-through global resources (textures, samplers, buffers)
175+
if globals, ok := w.funcPassThroughGlobals[handle]; ok {
176+
for _, gHandle := range globals {
177+
if paramCount > 0 {
178+
w.write(",\n ")
179+
}
180+
w.writePassThroughParam(gHandle)
181+
paramCount++
182+
}
78183
}
79184

80185
w.write(") {\n")
@@ -498,6 +603,34 @@ func (w *Writer) writeEntryPointOutputStruct(epIdx int, ep *ir.EntryPoint, fn *i
498603
return structName, true
499604
}
500605

606+
// writePassThroughParam writes a global variable as a pass-through parameter
607+
// for a helper function. Unlike entry point params, these have no [[binding]] attributes.
608+
func (w *Writer) writePassThroughParam(handle uint32) {
609+
global := &w.module.GlobalVariables[handle]
610+
name := w.getName(nameKey{kind: nameKeyGlobalVariable, handle1: handle})
611+
typeInfo := &w.module.Types[global.Type]
612+
613+
switch inner := typeInfo.Inner.(type) {
614+
case ir.SamplerType:
615+
w.write("%ssampler %s", Namespace, name)
616+
617+
case ir.ImageType:
618+
typeName := w.imageTypeName(inner, StorageAccess(0))
619+
w.write("%s %s", typeName, name)
620+
621+
default:
622+
// Buffer types (uniform, storage)
623+
space := addressSpaceName(global.Space)
624+
typeName := w.writeTypeName(global.Type, StorageAccess(0))
625+
626+
if space == spaceConstant || space == spaceDevice {
627+
w.write("%s %s& %s", space, typeName, name)
628+
} else {
629+
w.write("%s %s", typeName, name)
630+
}
631+
}
632+
}
633+
501634
// writeGlobalResourceParam writes a global resource as an entry point parameter.
502635
func (w *Writer) writeGlobalResourceParam(handle uint32, global *ir.GlobalVariable) error {
503636
name := w.getName(nameKey{kind: nameKeyGlobalVariable, handle1: handle})

msl/statements.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,18 @@ func (w *Writer) writeCall(call ir.StmtCall) error {
473473
}
474474
}
475475

476+
// Pass-through global resources (textures, samplers, buffers)
477+
if globals, ok := w.funcPassThroughGlobals[call.Function]; ok {
478+
hasArgs := len(call.Arguments) > 0
479+
for i, gHandle := range globals {
480+
if hasArgs || i > 0 {
481+
w.write(", ")
482+
}
483+
name := w.getName(nameKey{kind: nameKeyGlobalVariable, handle1: gHandle})
484+
w.write("%s", name)
485+
}
486+
}
487+
476488
w.write(");\n")
477489
return nil
478490
}

0 commit comments

Comments
 (0)