Skip to content

Commit ab82318

Browse files
authored
fix: HLSL row_major matrices + GLSL namedExpressions leak (v0.14.1)
* fix(hlsl): row_major matrices, mul() reversal, unique entry points, typed call results - Add row_major qualifier for matrix-typed struct members and cbuffer declarations (fixes WGSL M[i] column access → HLSL row access mismatch that caused DX12 MSDF text to be invisible due to w=-378 clipping) - Reverse mul() arguments for matrix multiply (row_major transposes storage, so mul(right, left) matches WGSL left * right semantics) - Use stage prefix + original name for unique entry point names (vs_vs_main, ps_fs_main, ps_fs_main_outline, ps_fs_main_shadow) - Declare typed local variables for function call results (_crN) - Map output local variable to _output with output struct type - Add containsMatrix helper for arrays-of-matrices - Add TestE2E_MatrixRowMajor, TestE2E_MatrixMulReversed tests - Update existing tests for row_major assertions * fix(glsl): clear namedExpressions between functions GLSL writer shared namedExpressions map across all functions in a module. When compiling multi-function shaders (e.g., MSDF text), expression names from earlier functions leaked into later ones. Example: sampleSD() stored namedExpressions[8]="_fc8", then in vs_main expression handle 8 (literal 0 for transform[0]) resolved to "_fc8" instead of "0", producing invalid GLSL: uniforms.transform[_fc8] // undeclared identifier Fix: reset namedExpressions at start of writeFunction() and writeEntryPoint(), matching HLSL/MSL backends which already do this. * refactor(hlsl): reduce writeEntryPointWithIO complexity, update CHANGELOG for v0.14.1 Extract writeReturnSemantic and writeEntryPointLocalVars helpers to bring cognitive complexity from 32 to under 30 (gocognit limit). Add CHANGELOG entry for v0.14.1 (HLSL row_major + GLSL namedExpressions fixes).
1 parent 97fe275 commit ab82318

File tree

9 files changed

+484
-61
lines changed

9 files changed

+484
-61
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ 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.1] - 2026-02-21
9+
10+
### Fixed
11+
12+
#### HLSL Backend
13+
- `row_major` qualifier for matrix struct members in cbuffer/uniform blocks — DX12 `M[i]` column access was returning rows instead of columns, causing transposed transforms and invisible geometry
14+
- `mul(right, left)` argument reversal for `row_major` matrices — HLSL `mul()` semantics differ from WGSL `*` operator when layout changes
15+
- Unique entry point names — prevent HLSL duplicate function errors when multiple entry points reference the same function
16+
- Typed call results — function calls now use correct return type instead of void
17+
18+
#### GLSL Backend
19+
- Clear `namedExpressions` between function compilations — expression handle names from one WGSL function were leaking into subsequent functions, causing `undeclared identifier` errors in GLES shaders
20+
821
## [0.14.0] - 2026-02-21
922

1023
Major WGSL language coverage expansion: 15/15 Essential reference shaders from Rust naga test suite now compile to valid SPIR-V.

glsl/writer.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,7 @@ func (w *Writer) writeFunction(handle ir.FunctionHandle, fn *ir.Function) error
722722
w.currentFunction = fn
723723
w.currentFuncHandle = handle
724724
w.localNames = make(map[uint32]string)
725+
w.namedExpressions = make(map[ir.ExpressionHandle]string)
725726

726727
name := w.names[nameKey{kind: nameKeyFunction, handle1: uint32(handle)}]
727728

@@ -782,6 +783,7 @@ func (w *Writer) writeEntryPoint(epIdx int, ep *ir.EntryPoint) error {
782783
w.currentFunction = fn
783784
w.currentFuncHandle = ep.Function
784785
w.localNames = make(map[uint32]string)
786+
w.namedExpressions = make(map[ir.ExpressionHandle]string)
785787
w.inEntryPoint = true
786788
w.entryPointResult = fn.Result
787789
w.epStructArgs = make(map[uint32]*epStructInfo)

hlsl/expressions.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ func (w *Writer) writeUnaryExpression(e ir.ExprUnary) error {
406406

407407
// writeBinaryExpression writes a binary operation.
408408
//
409-
//nolint:gocyclo,cyclop // Binary operator mapping requires many cases (19 operators)
409+
//nolint:gocyclo,cyclop,funlen // Binary operator mapping requires many cases (19 operators + matrix mul)
410410
func (w *Writer) writeBinaryExpression(e ir.ExprBinary) error {
411411
var op string
412412
switch e.Op {
@@ -415,6 +415,25 @@ func (w *Writer) writeBinaryExpression(e ir.ExprBinary) error {
415415
case ir.BinarySubtract:
416416
op = "-"
417417
case ir.BinaryMultiply:
418+
// When either operand is a matrix, use mul() with reversed args.
419+
// HLSL row_major storage transposes the matrix, so mul(right, left)
420+
// produces the correct result matching WGSL's left * right semantics.
421+
leftType := w.getExpressionTypeInner(e.Left)
422+
rightType := w.getExpressionTypeInner(e.Right)
423+
_, leftIsMatrix := leftType.(ir.MatrixType)
424+
_, rightIsMatrix := rightType.(ir.MatrixType)
425+
if leftIsMatrix || rightIsMatrix {
426+
w.out.WriteString("mul(")
427+
if err := w.writeExpression(e.Right); err != nil {
428+
return fmt.Errorf("binary right: %w", err)
429+
}
430+
w.out.WriteString(", ")
431+
if err := w.writeExpression(e.Left); err != nil {
432+
return fmt.Errorf("binary left: %w", err)
433+
}
434+
w.out.WriteString(")")
435+
return nil
436+
}
418437
op = "*"
419438
case ir.BinaryDivide:
420439
op = "/"
@@ -1050,9 +1069,10 @@ func (w *Writer) writeImageQueryExpression(e ir.ExprImageQuery) error {
10501069
// =============================================================================
10511070

10521071
// writeCallResultExpression writes a reference to a call result.
1072+
// Normally this is NOT reached because writeCallStatement registers the
1073+
// result in namedExpressions, and writeExpression returns the cached name
1074+
// before dispatching here. This fallback exists for safety.
10531075
func (w *Writer) writeCallResultExpression(e ir.ExprCallResult) error {
1054-
// The call result is stored in a temporary by the call statement
1055-
// This references that temporary
10561076
name := w.names[nameKey{kind: nameKeyFunction, handle1: uint32(e.Function)}]
10571077
fmt.Fprintf(&w.out, "_%s_result", name)
10581078
return nil
@@ -1120,8 +1140,6 @@ func (w *Writer) writeExpressionToString(handle ir.ExpressionHandle) (string, er
11201140
}
11211141

11221142
// getExpressionTypeInner returns just the TypeInner for an expression.
1123-
//
1124-
//nolint:unused // Helper prepared for integration when needed
11251143
func (w *Writer) getExpressionTypeInner(handle ir.ExpressionHandle) ir.TypeInner {
11261144
typ := w.getExpressionType(handle)
11271145
if typ != nil {

hlsl/functions.go

Lines changed: 64 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -237,21 +237,7 @@ func (w *Writer) writeEntryPointWithIO(epIdx int, ep *ir.EntryPoint) error {
237237
// Write function signature
238238
w.writeEntryPointSignature(returnType, epName, ep, fn, inputStructName, hasInputStruct)
239239

240-
// Add return semantic for simple (non-struct) return types.
241-
// Fragment shader @location(N) maps to SV_TargetN (not TEXCOORD).
242-
if !hasOutputStruct && fn.Result != nil && fn.Result.Binding != nil {
243-
var semantic string
244-
if ep.Stage == ir.StageFragment {
245-
if loc, ok := (*fn.Result.Binding).(ir.LocationBinding); ok {
246-
semantic = fmt.Sprintf("SV_Target%d", loc.Location)
247-
} else {
248-
semantic = w.getSemanticFromBinding(*fn.Result.Binding, 0)
249-
}
250-
} else {
251-
semantic = w.getSemanticFromBinding(*fn.Result.Binding, 0)
252-
}
253-
fmt.Fprintf(&w.out, " : %s", semantic)
254-
}
240+
w.writeReturnSemantic(ep, fn, hasOutputStruct)
255241

256242
w.writeLine(" {")
257243
w.pushIndent()
@@ -261,31 +247,14 @@ func (w *Writer) writeEntryPointWithIO(epIdx int, ep *ir.EntryPoint) error {
261247
w.writeInputExtraction(ep, fn, structArgs)
262248
}
263249

264-
// Write local variables with optional init expressions
265-
for localIdx, local := range fn.LocalVars {
266-
localName := w.namer.call(local.Name)
267-
w.localNames[uint32(localIdx)] = localName
268-
// HLSL arrays: type name[size], not type[size] name
269-
localType, arraySuffix := w.getTypeNameWithArraySuffix(local.Type)
270-
if local.Init != nil {
271-
w.writeIndent()
272-
fmt.Fprintf(&w.out, "%s %s%s = ", localType, localName, arraySuffix)
273-
if err := w.writeExpression(*local.Init); err != nil {
274-
w.popIndent()
275-
return fmt.Errorf("entry point local var init: %w", err)
276-
}
277-
w.out.WriteString(";\n")
278-
} else {
279-
w.writeLine("%s %s%s;", localType, localName, arraySuffix)
280-
}
281-
}
282-
283-
if len(fn.LocalVars) > 0 || hasInputStruct {
284-
w.writeLine("")
250+
outputLocalMapped, err := w.writeEntryPointLocalVars(fn, hasOutputStruct, outputStructName, hasInputStruct)
251+
if err != nil {
252+
w.popIndent()
253+
return err
285254
}
286255

287-
// Create output struct if needed
288-
if hasOutputStruct {
256+
// Create output struct if not already mapped from a local variable
257+
if hasOutputStruct && !outputLocalMapped {
289258
w.writeLine("%s _output;", outputStructName)
290259
w.writeLine("")
291260
}
@@ -296,7 +265,7 @@ func (w *Writer) writeEntryPointWithIO(epIdx int, ep *ir.EntryPoint) error {
296265
return err
297266
}
298267

299-
// Return output struct if needed
268+
// Return output struct if needed (fallback for control flow paths without explicit return)
300269
if hasOutputStruct {
301270
w.writeLine("return _output;")
302271
}
@@ -323,6 +292,62 @@ func (w *Writer) writeComputeAttributes(ep *ir.EntryPoint) {
323292
w.writeLine("[numthreads(%d, %d, %d)]", x, y, z)
324293
}
325294

295+
// writeReturnSemantic adds HLSL return semantic for simple (non-struct) return types.
296+
// Fragment shader @location(N) maps to SV_TargetN (not TEXCOORD).
297+
func (w *Writer) writeReturnSemantic(ep *ir.EntryPoint, fn *ir.Function, hasOutputStruct bool) {
298+
if hasOutputStruct || fn.Result == nil || fn.Result.Binding == nil {
299+
return
300+
}
301+
var semantic string
302+
if ep.Stage == ir.StageFragment {
303+
if loc, ok := (*fn.Result.Binding).(ir.LocationBinding); ok {
304+
semantic = fmt.Sprintf("SV_Target%d", loc.Location)
305+
} else {
306+
semantic = w.getSemanticFromBinding(*fn.Result.Binding, 0)
307+
}
308+
} else {
309+
semantic = w.getSemanticFromBinding(*fn.Result.Binding, 0)
310+
}
311+
fmt.Fprintf(&w.out, " : %s", semantic)
312+
}
313+
314+
// writeEntryPointLocalVars writes local variable declarations for an entry point.
315+
// When a local variable has the same type as the entry point result, it IS the
316+
// output variable — declared as _output with the output struct type so that HLSL
317+
// semantics (SV_Position, TEXCOORD) are attached correctly.
318+
// Returns whether an output local was mapped to _output.
319+
func (w *Writer) writeEntryPointLocalVars(fn *ir.Function, hasOutputStruct bool, outputStructName string, hasInputStruct bool) (bool, error) {
320+
outputLocalMapped := false
321+
for localIdx, local := range fn.LocalVars {
322+
localName := w.namer.call(local.Name)
323+
localType, arraySuffix := w.getTypeNameWithArraySuffix(local.Type)
324+
325+
if hasOutputStruct && !outputLocalMapped && fn.Result != nil && local.Type == fn.Result.Type {
326+
localName = "_output"
327+
localType = outputStructName
328+
outputLocalMapped = true
329+
}
330+
331+
w.localNames[uint32(localIdx)] = localName
332+
333+
if local.Init != nil {
334+
w.writeIndent()
335+
fmt.Fprintf(&w.out, "%s %s%s = ", localType, localName, arraySuffix)
336+
if err := w.writeExpression(*local.Init); err != nil {
337+
return false, fmt.Errorf("entry point local var init: %w", err)
338+
}
339+
w.out.WriteString(";\n")
340+
} else {
341+
w.writeLine("%s %s%s;", localType, localName, arraySuffix)
342+
}
343+
}
344+
345+
if len(fn.LocalVars) > 0 || hasInputStruct {
346+
w.writeLine("")
347+
}
348+
return outputLocalMapped, nil
349+
}
350+
326351
// writeEntryPointSignature writes the function signature for an entry point.
327352
func (w *Writer) writeEntryPointSignature(returnType, epName string, ep *ir.EntryPoint, fn *ir.Function, inputStructName string, hasInputStruct bool) {
328353
w.writeIndent()

0 commit comments

Comments
 (0)