From 383ce9812962712ff02280208af3e4e5ce16989d Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Mon, 20 Apr 2026 21:43:05 -0400 Subject: [PATCH 1/2] Add WebAssembly (js/wasm) backend Parallel WebGL2 implementation of Shader, Texture, VertexSlice, Frame, and VertexArray under the `js && wasm` build tag; existing files now tagged `!js` so desktop builds are untouched. Includes a GLSL 330 core to 300 es preprocessor so consumers can run their existing shaders in the browser unchanged. Also adds `BlendFuncSeparate` and `BlendEquation` helpers needed by downstream pixel consumers. Co-Authored-By: Claude Opus 4.7 --- frame.go | 2 + gl_wasm.go | 65 ++++++ glhf_wasm.go | 563 +++++++++++++++++++++++++++++++++++++++++++++++++++ orphan.go | 32 +++ shader.go | 2 + texture.go | 2 + util.go | 2 + vertex.go | 2 + 8 files changed, 670 insertions(+) create mode 100644 gl_wasm.go create mode 100644 glhf_wasm.go diff --git a/frame.go b/frame.go index a22828f..360d3c9 100644 --- a/frame.go +++ b/frame.go @@ -1,3 +1,5 @@ +//go:build !js + package glhf import ( diff --git a/gl_wasm.go b/gl_wasm.go new file mode 100644 index 0000000..2059e02 --- /dev/null +++ b/gl_wasm.go @@ -0,0 +1,65 @@ +//go:build js && wasm + +package glhf + +import ( + "syscall/js" + "unsafe" +) + +// Package-level WebGL2 state. Callers (pixel backend, standalone consumers) must +// invoke SetContext exactly once, after creating a WebGL2RenderingContext, before +// any glhf.Init / New* / Make* call runs. + +var ( + gl js.Value + glReady bool + uint8ArrayCtor js.Value + f32ArrayCtor js.Value +) + +// SetContext installs the WebGL2 rendering context used by all glhf calls. +func SetContext(ctx js.Value) { + gl = ctx + uint8ArrayCtor = js.Global().Get("Uint8Array") + f32ArrayCtor = js.Global().Get("Float32Array") + glReady = true +} + +// Context returns the installed WebGL2 context (empty js.Value if unset). +func Context() js.Value { return gl } + +// glEnum looks up a WebGL constant by name (e.g. "TEXTURE_2D"). +func glEnum(name string) int { return gl.Get(name).Int() } + +// uint8ToJS copies a Go byte slice into a new JS Uint8Array. +func uint8ToJS(b []byte) js.Value { + u8 := uint8ArrayCtor.New(len(b)) + if len(b) > 0 { + js.CopyBytesToJS(u8, b) + } + return u8 +} + +// float32ToJS copies a Go float32 slice into a new JS Float32Array. +func float32ToJS(f []float32) js.Value { + if len(f) == 0 { + return f32ArrayCtor.New(0) + } + b := unsafe.Slice((*byte)(unsafe.Pointer(&f[0])), len(f)*4) + u8 := uint8ToJS(b) + return f32ArrayCtor.New(u8.Get("buffer"), u8.Get("byteOffset"), len(f)) +} + +// float32FromJS copies a JS Float32Array back into a Go float32 slice. +func float32FromJS(arr js.Value, n int) []float32 { + out := make([]float32, n) + if n == 0 { + return out + } + // Read bytes via a Uint8Array view over the same buffer. + u8 := uint8ArrayCtor.New(arr.Get("buffer"), arr.Get("byteOffset"), n*4) + b := unsafe.Slice((*byte)(unsafe.Pointer(&out[0])), n*4) + js.CopyBytesToGo(b, u8) + return out +} diff --git a/glhf_wasm.go b/glhf_wasm.go new file mode 100644 index 0000000..c4d7eb8 --- /dev/null +++ b/glhf_wasm.go @@ -0,0 +1,563 @@ +//go:build js && wasm + +// WebGL2 backend for glhf. Mirrors the exported surface of the desktop OpenGL 3.3 +// backend (shader.go, texture.go, frame.go, vertex.go, orphan.go, util.go) and +// dispatches to a WebGL2RenderingContext via syscall/js. + +package glhf + +import ( + "fmt" + "strings" + "syscall/js" + + "github.com/go-gl/mathgl/mgl32" +) + +// --- Shader ---------------------------------------------------------------- + +type Shader struct { + program js.Value + vertexFmt AttrFormat + uniformFmt AttrFormat + uniformLoc []js.Value + id uint32 +} + +var nextID uint32 + +func newID() uint32 { + nextID++ + return nextID +} + +// preprocessShaderForES300 adapts GLSL 330 core source (written for the +// desktop backend) to WebGL2's required GLSL ES 3.00. If the source already +// declares an ES version, it is returned untouched. Otherwise: +// - the `#version ...` line is rewritten to `#version 300 es` +// - `precision highp float; precision highp int;` is injected on the next +// line so fragment shaders get mandatory precision qualifiers. +func preprocessShaderForES300(src string) string { + trimmed := strings.TrimLeft(src, " \t\r\n") + if strings.Contains(trimmed, "#version 300 es") || + strings.Contains(trimmed, "#version 310 es") { + return src + } + + // Locate (and drop) the first #version line, if any. + lines := strings.SplitN(trimmed, "\n", 2) + rest := trimmed + if len(lines) > 0 && strings.HasPrefix(strings.TrimSpace(lines[0]), "#version") { + if len(lines) == 2 { + rest = lines[1] + } else { + rest = "" + } + } + return "#version 300 es\nprecision highp float;\nprecision highp int;\n" + rest +} + +func compileShader(kind int, src string) (js.Value, error) { + src = preprocessShaderForES300(src) + sh := gl.Call("createShader", kind) + gl.Call("shaderSource", sh, src) + gl.Call("compileShader", sh) + if !gl.Call("getShaderParameter", sh, glEnum("COMPILE_STATUS")).Bool() { + log := gl.Call("getShaderInfoLog", sh).String() + gl.Call("deleteShader", sh) + kindName := "vertex" + if kind == glEnum("FRAGMENT_SHADER") { + kindName = "fragment" + } + return js.Null(), fmt.Errorf("error compiling %s shader: %s", kindName, log) + } + return sh, nil +} + +func NewShader(vertexFmt, uniformFmt AttrFormat, vertexShader, fragmentShader string) (*Shader, error) { + if !glReady { + return nil, fmt.Errorf("glhf: WebGL context not set; call glhf.SetContext first") + } + + vs, err := compileShader(glEnum("VERTEX_SHADER"), vertexShader) + if err != nil { + return nil, err + } + fs, err := compileShader(glEnum("FRAGMENT_SHADER"), fragmentShader) + if err != nil { + gl.Call("deleteShader", vs) + return nil, err + } + + prog := gl.Call("createProgram") + gl.Call("attachShader", prog, vs) + gl.Call("attachShader", prog, fs) + gl.Call("linkProgram", prog) + gl.Call("deleteShader", vs) + gl.Call("deleteShader", fs) + if !gl.Call("getProgramParameter", prog, glEnum("LINK_STATUS")).Bool() { + log := gl.Call("getProgramInfoLog", prog).String() + gl.Call("deleteProgram", prog) + return nil, fmt.Errorf("error linking shader program: %s", log) + } + + s := &Shader{ + program: prog, + vertexFmt: vertexFmt, + uniformFmt: uniformFmt, + uniformLoc: make([]js.Value, len(uniformFmt)), + id: newID(), + } + for i, u := range uniformFmt { + s.uniformLoc[i] = gl.Call("getUniformLocation", prog, u.Name) + } + return s, nil +} + +func (s *Shader) ID() uint32 { return s.id } +func (s *Shader) VertexFormat() AttrFormat { return s.vertexFmt } +func (s *Shader) UniformFormat() AttrFormat { return s.uniformFmt } +func (s *Shader) Begin() { gl.Call("useProgram", s.program) } +func (s *Shader) End() { gl.Call("useProgram", js.Null()) } + +func (s *Shader) SetUniformAttr(uniform int, value interface{}) (ok bool) { + loc := s.uniformLoc[uniform] + if loc.IsNull() || !loc.Truthy() { + return false + } + switch s.uniformFmt[uniform].Type { + case Int: + gl.Call("uniform1i", loc, int(value.(int32))) + case Float: + gl.Call("uniform1f", loc, float64(value.(float32))) + case Vec2: + v := value.(mgl32.Vec2) + gl.Call("uniform2fv", loc, float32ToJS(v[:])) + case Vec3: + v := value.(mgl32.Vec3) + gl.Call("uniform3fv", loc, float32ToJS(v[:])) + case Vec4: + v := value.(mgl32.Vec4) + gl.Call("uniform4fv", loc, float32ToJS(v[:])) + case Mat2: + v := value.(mgl32.Mat2) + gl.Call("uniformMatrix2fv", loc, false, float32ToJS(v[:])) + case Mat23: + v := value.(mgl32.Mat2x3) + gl.Call("uniformMatrix2x3fv", loc, false, float32ToJS(v[:])) + case Mat24: + v := value.(mgl32.Mat2x4) + gl.Call("uniformMatrix2x4fv", loc, false, float32ToJS(v[:])) + case Mat3: + v := value.(mgl32.Mat3) + gl.Call("uniformMatrix3fv", loc, false, float32ToJS(v[:])) + case Mat32: + v := value.(mgl32.Mat3x2) + gl.Call("uniformMatrix3x2fv", loc, false, float32ToJS(v[:])) + case Mat34: + v := value.(mgl32.Mat3x4) + gl.Call("uniformMatrix3x4fv", loc, false, float32ToJS(v[:])) + case Mat4: + v := value.(mgl32.Mat4) + gl.Call("uniformMatrix4fv", loc, false, float32ToJS(v[:])) + case Mat42: + v := value.(mgl32.Mat4x2) + gl.Call("uniformMatrix4x2fv", loc, false, float32ToJS(v[:])) + case Mat43: + v := value.(mgl32.Mat4x3) + gl.Call("uniformMatrix4x3fv", loc, false, float32ToJS(v[:])) + default: + panic("set uniform attr: invalid attribute type") + } + return true +} + +// --- Texture --------------------------------------------------------------- + +type Texture struct { + handle js.Value + width, height int + smooth bool + id uint32 +} + +func NewTexture(width, height int, smooth bool, pixels []uint8) *Texture { + t := &Texture{ + handle: gl.Call("createTexture"), + width: width, + height: height, + id: newID(), + } + prev := gl.Call("getParameter", glEnum("TEXTURE_BINDING_2D")) + gl.Call("bindTexture", glEnum("TEXTURE_2D"), t.handle) + + var data js.Value + if len(pixels) == width*height*4 { + data = uint8ToJS(pixels) + } else { + data = uint8ToJS(make([]byte, width*height*4)) + } + gl.Call("texImage2D", + glEnum("TEXTURE_2D"), 0, glEnum("RGBA"), + width, height, 0, + glEnum("RGBA"), glEnum("UNSIGNED_BYTE"), + data, + ) + + // WebGL2 does not support CLAMP_TO_BORDER; fall back to CLAMP_TO_EDGE. + gl.Call("texParameteri", glEnum("TEXTURE_2D"), glEnum("TEXTURE_WRAP_S"), glEnum("CLAMP_TO_EDGE")) + gl.Call("texParameteri", glEnum("TEXTURE_2D"), glEnum("TEXTURE_WRAP_T"), glEnum("CLAMP_TO_EDGE")) + + t.SetSmooth(smooth) + + gl.Call("bindTexture", glEnum("TEXTURE_2D"), prev) + return t +} + +func (t *Texture) ID() uint32 { return t.id } +func (t *Texture) Width() int { return t.width } +func (t *Texture) Height() int { return t.height } +func (t *Texture) Smooth() bool { return t.smooth } +func (t *Texture) Handle() js.Value { return t.handle } + +func (t *Texture) SetSmooth(smooth bool) { + t.smooth = smooth + filter := glEnum("NEAREST") + if smooth { + filter = glEnum("LINEAR") + } + gl.Call("texParameteri", glEnum("TEXTURE_2D"), glEnum("TEXTURE_MIN_FILTER"), filter) + gl.Call("texParameteri", glEnum("TEXTURE_2D"), glEnum("TEXTURE_MAG_FILTER"), filter) +} + +func (t *Texture) Begin() { + gl.Call("bindTexture", glEnum("TEXTURE_2D"), t.handle) +} + +func (t *Texture) End() { + gl.Call("bindTexture", glEnum("TEXTURE_2D"), js.Null()) +} + +func (t *Texture) SetPixels(x, y, w, h int, pixels []uint8) { + if len(pixels) != w*h*4 { + panic("set pixels: wrong number of pixels") + } + gl.Call("texSubImage2D", + glEnum("TEXTURE_2D"), 0, + x, y, w, h, + glEnum("RGBA"), glEnum("UNSIGNED_BYTE"), + uint8ToJS(pixels), + ) +} + +// Pixels reads a sub-rectangle from the texture. WebGL has no glGetTexImage, so +// this attaches the texture to a transient framebuffer and uses readPixels. +func (t *Texture) Pixels(x, y, w, h int) []uint8 { + fbo := gl.Call("createFramebuffer") + prev := gl.Call("getParameter", glEnum("FRAMEBUFFER_BINDING")) + gl.Call("bindFramebuffer", glEnum("FRAMEBUFFER"), fbo) + gl.Call("framebufferTexture2D", + glEnum("FRAMEBUFFER"), glEnum("COLOR_ATTACHMENT0"), + glEnum("TEXTURE_2D"), t.handle, 0, + ) + + buf := uint8ToJS(make([]byte, w*h*4)) + gl.Call("readPixels", x, y, w, h, glEnum("RGBA"), glEnum("UNSIGNED_BYTE"), buf) + + out := make([]byte, w*h*4) + js.CopyBytesToGo(out, buf) + + gl.Call("bindFramebuffer", glEnum("FRAMEBUFFER"), prev) + gl.Call("deleteFramebuffer", fbo) + return out +} + +// --- Frame ----------------------------------------------------------------- + +type Frame struct { + fb js.Value + tex *Texture + id uint32 +} + +func NewFrame(width, height int, smooth bool) *Frame { + f := &Frame{ + fb: gl.Call("createFramebuffer"), + tex: NewTexture(width, height, smooth, nil), + id: newID(), + } + prev := gl.Call("getParameter", glEnum("FRAMEBUFFER_BINDING")) + gl.Call("bindFramebuffer", glEnum("FRAMEBUFFER"), f.fb) + gl.Call("framebufferTexture2D", + glEnum("FRAMEBUFFER"), glEnum("COLOR_ATTACHMENT0"), + glEnum("TEXTURE_2D"), f.tex.handle, 0, + ) + gl.Call("bindFramebuffer", glEnum("FRAMEBUFFER"), prev) + return f +} + +func (f *Frame) ID() uint32 { return f.id } +func (f *Frame) Texture() *Texture { return f.tex } +func (f *Frame) Begin() { gl.Call("bindFramebuffer", glEnum("FRAMEBUFFER"), f.fb) } +func (f *Frame) End() { gl.Call("bindFramebuffer", glEnum("FRAMEBUFFER"), js.Null()) } + +// Blit copies a rectangle from this Frame to dst (or default framebuffer if nil). +// Uses WebGL2 blitFramebuffer. +func (f *Frame) Blit(dst *Frame, sx0, sy0, sx1, sy1, dx0, dy0, dx1, dy1 int) { + var dstFB js.Value + if dst != nil { + dstFB = dst.fb + } else { + dstFB = js.Null() + } + + prevRead := gl.Call("getParameter", glEnum("READ_FRAMEBUFFER_BINDING")) + prevDraw := gl.Call("getParameter", glEnum("DRAW_FRAMEBUFFER_BINDING")) + gl.Call("bindFramebuffer", glEnum("READ_FRAMEBUFFER"), f.fb) + gl.Call("bindFramebuffer", glEnum("DRAW_FRAMEBUFFER"), dstFB) + + filter := glEnum("NEAREST") + if f.tex.smooth { + filter = glEnum("LINEAR") + } + + gl.Call("blitFramebuffer", + sx0, sy0, sx1, sy1, + dx0, dy0, dx1, dy1, + glEnum("COLOR_BUFFER_BIT"), filter, + ) + + gl.Call("bindFramebuffer", glEnum("READ_FRAMEBUFFER"), prevRead) + gl.Call("bindFramebuffer", glEnum("DRAW_FRAMEBUFFER"), prevDraw) +} + +// --- VertexSlice ----------------------------------------------------------- + +type VertexSlice struct { + va *vertexArray + i, j int +} + +type vertexArray struct { + vao js.Value + vbo js.Value + cap int + format AttrFormat + stride int + offset []int + shader *Shader + // shadow mirrors the GPU buffer so VertexData() can return without a GPU + // readback. getBufferSubData on a DYNAMIC_DRAW buffer causes pipeline + // stalls (WebGL warns); keeping the data CPU-side sidesteps that. + shadow []float32 +} + +const vertexArrayMinCap = 4 + +func MakeVertexSlice(shader *Shader, length, capacity int) *VertexSlice { + if length > capacity { + panic("failed to make vertex slice: len > cap") + } + if capacity < vertexArrayMinCap { + capacity = vertexArrayMinCap + } + va := newVertexArray(shader, capacity) + return &VertexSlice{va: va, i: 0, j: length} +} + +func newVertexArray(shader *Shader, capacity int) *vertexArray { + stride := shader.VertexFormat().Size() + va := &vertexArray{ + vao: gl.Call("createVertexArray"), + vbo: gl.Call("createBuffer"), + cap: capacity, + format: shader.VertexFormat(), + stride: stride, + offset: make([]int, len(shader.VertexFormat())), + shader: shader, + shadow: make([]float32, capacity*(stride/4)), + } + off := 0 + for i, attr := range va.format { + switch attr.Type { + case Float, Vec2, Vec3, Vec4: + default: + panic("failed to create vertex array: invalid attribute type") + } + va.offset[i] = off + off += attr.Type.Size() + } + + prevVAO := gl.Call("getParameter", glEnum("VERTEX_ARRAY_BINDING")) + prevVBO := gl.Call("getParameter", glEnum("ARRAY_BUFFER_BINDING")) + gl.Call("bindVertexArray", va.vao) + gl.Call("bindBuffer", glEnum("ARRAY_BUFFER"), va.vbo) + + gl.Call("bufferData", glEnum("ARRAY_BUFFER"), + capacity*stride, glEnum("DYNAMIC_DRAW")) + + for i, attr := range va.format { + loc := gl.Call("getAttribLocation", shader.program, attr.Name).Int() + if loc < 0 { + continue + } + var size int + switch attr.Type { + case Float: + size = 1 + case Vec2: + size = 2 + case Vec3: + size = 3 + case Vec4: + size = 4 + } + gl.Call("vertexAttribPointer", loc, size, glEnum("FLOAT"), false, stride, va.offset[i]) + gl.Call("enableVertexAttribArray", loc) + } + + gl.Call("bindBuffer", glEnum("ARRAY_BUFFER"), prevVBO) + gl.Call("bindVertexArray", prevVAO) + return va +} + +func (vs *VertexSlice) VertexFormat() AttrFormat { return vs.va.format } +func (vs *VertexSlice) Stride() int { return vs.va.stride / 4 } +func (vs *VertexSlice) Len() int { return vs.j - vs.i } +func (vs *VertexSlice) Cap() int { return vs.va.cap - vs.i } + +func (vs *VertexSlice) SetLen(length int) { + vs.End() + *vs = vs.grow(length) + vs.Begin() +} + +func (vs VertexSlice) grow(length int) VertexSlice { + if length <= vs.Cap() { + return VertexSlice{va: vs.va, i: vs.i, j: vs.i + length} + } + newCap := vs.Cap() + if newCap < 1024 { + newCap += newCap + } else { + newCap += newCap / 4 + } + if newCap < length { + newCap = length + } + newVs := VertexSlice{ + va: newVertexArray(vs.va.shader, newCap), + i: 0, + j: length, + } + newVs.Begin() + newVs.Slice(0, vs.Len()).SetVertexData(vs.VertexData()) + newVs.End() + return newVs +} + +func (vs *VertexSlice) Slice(i, j int) *VertexSlice { + if i < 0 || j < i || j > vs.va.cap { + panic("failed to slice vertex slice: index out of range") + } + return &VertexSlice{va: vs.va, i: vs.i + i, j: vs.i + j} +} + +func (vs *VertexSlice) SetVertexData(data []float32) { + if len(data)/vs.Stride() != vs.Len() { + panic("set vertex data: wrong length of vertices") + } + if len(data) == 0 { + return + } + copy(vs.va.shadow[vs.i*vs.Stride():], data) + gl.Call("bufferSubData", glEnum("ARRAY_BUFFER"), + vs.i*vs.va.stride, float32ToJS(data)) +} + +func (vs *VertexSlice) VertexData() []float32 { + n := (vs.j - vs.i) * vs.Stride() + if n == 0 { + return nil + } + out := make([]float32, n) + copy(out, vs.va.shadow[vs.i*vs.Stride():vs.j*vs.Stride()]) + return out +} + +func (vs *VertexSlice) Draw() { + gl.Call("drawArrays", glEnum("TRIANGLES"), vs.i, vs.j-vs.i) +} + +func (vs *VertexSlice) Begin() { + gl.Call("bindVertexArray", vs.va.vao) + gl.Call("bindBuffer", glEnum("ARRAY_BUFFER"), vs.va.vbo) +} + +func (vs *VertexSlice) End() { + gl.Call("bindBuffer", glEnum("ARRAY_BUFFER"), js.Null()) + gl.Call("bindVertexArray", js.Null()) +} + +// --- Global GL state ------------------------------------------------------- + +func Init() { + if !glReady { + panic("glhf: WebGL context not set; call glhf.SetContext first") + } + gl.Call("enable", glEnum("BLEND")) + gl.Call("enable", glEnum("SCISSOR_TEST")) + gl.Call("blendEquation", glEnum("FUNC_ADD")) +} + +func Clear(r, g, b, a float32) { + gl.Call("clearColor", float64(r), float64(g), float64(b), float64(a)) + gl.Call("clear", glEnum("COLOR_BUFFER_BIT")) +} + +func Bounds(x, y, w, h int) { + gl.Call("viewport", x, y, w, h) + gl.Call("scissor", x, y, w, h) +} + +func BlendFunc(src, dst BlendFactor) { + gl.Call("blendFunc", int(src), int(dst)) +} + +func BlendFuncSeparate(srcRGB, dstRGB, srcAlpha, dstAlpha BlendFactor) { + gl.Call("blendFuncSeparate", int(srcRGB), int(dstRGB), int(srcAlpha), int(dstAlpha)) +} + +type BlendEquationMode int + +const ( + FuncAdd = BlendEquationMode(0x8006) + FuncSubtract = BlendEquationMode(0x800A) + FuncReverseSubtract = BlendEquationMode(0x800B) +) + +func BlendEquation(mode BlendEquationMode) { + gl.Call("blendEquation", int(mode)) +} + +// ActiveTexture selects the active texture unit (0-based). Unit N maps to +// WebGL's TEXTURE0 + N (0x84C0 + N). +func ActiveTexture(unit int) { + gl.Call("activeTexture", 0x84C0+unit) +} + +type BlendFactor int + +// WebGL2 numeric enum values (match desktop OpenGL). +const ( + One = BlendFactor(1) + Zero = BlendFactor(0) + SrcAlpha = BlendFactor(0x0302) + DstAlpha = BlendFactor(0x0304) + OneMinusSrcAlpha = BlendFactor(0x0303) + OneMinusDstAlpha = BlendFactor(0x0305) + SrcColor = BlendFactor(0x0300) + DstColor = BlendFactor(0x0306) + OneMinusSrcColor = BlendFactor(0x0301) + OneMinusDstColor = BlendFactor(0x0307) +) diff --git a/orphan.go b/orphan.go index 11ecfbe..c9296ec 100644 --- a/orphan.go +++ b/orphan.go @@ -1,3 +1,5 @@ +//go:build !js + package glhf import "github.com/go-gl/gl/v3.3-core/gl" @@ -43,9 +45,39 @@ const ( DstAlpha = BlendFactor(gl.DST_ALPHA) OneMinusSrcAlpha = BlendFactor(gl.ONE_MINUS_SRC_ALPHA) OneMinusDstAlpha = BlendFactor(gl.ONE_MINUS_DST_ALPHA) + SrcColor = BlendFactor(gl.SRC_COLOR) + DstColor = BlendFactor(gl.DST_COLOR) + OneMinusSrcColor = BlendFactor(gl.ONE_MINUS_SRC_COLOR) + OneMinusDstColor = BlendFactor(gl.ONE_MINUS_DST_COLOR) ) // BlendFunc sets the source and destination blend factor. func BlendFunc(src, dst BlendFactor) { gl.BlendFunc(uint32(src), uint32(dst)) } + +// BlendFuncSeparate sets separate blend factors for RGB and alpha channels. +func BlendFuncSeparate(srcRGB, dstRGB, srcAlpha, dstAlpha BlendFactor) { + gl.BlendFuncSeparate(uint32(srcRGB), uint32(dstRGB), uint32(srcAlpha), uint32(dstAlpha)) +} + +// BlendEquationMode identifies a blend equation. +type BlendEquationMode int + +// Supported blend equations. +const ( + FuncAdd = BlendEquationMode(gl.FUNC_ADD) + FuncSubtract = BlendEquationMode(gl.FUNC_SUBTRACT) + FuncReverseSubtract = BlendEquationMode(gl.FUNC_REVERSE_SUBTRACT) +) + +// BlendEquation sets the active blend equation. +func BlendEquation(mode BlendEquationMode) { + gl.BlendEquation(uint32(mode)) +} + +// ActiveTexture selects the active texture unit (0-based index). Unit N +// corresponds to GL_TEXTURE0 + N. +func ActiveTexture(unit int) { + gl.ActiveTexture(uint32(gl.TEXTURE0 + int32(unit))) +} diff --git a/shader.go b/shader.go index a350d3a..81d7a09 100644 --- a/shader.go +++ b/shader.go @@ -1,3 +1,5 @@ +//go:build !js + package glhf import ( diff --git a/texture.go b/texture.go index 0365d80..5df4c6e 100644 --- a/texture.go +++ b/texture.go @@ -1,3 +1,5 @@ +//go:build !js + package glhf import ( diff --git a/util.go b/util.go index 725b55b..8647352 100644 --- a/util.go +++ b/util.go @@ -1,3 +1,5 @@ +//go:build !js + package glhf import "github.com/go-gl/gl/v3.3-core/gl" diff --git a/vertex.go b/vertex.go index 8c80356..1d7c020 100644 --- a/vertex.go +++ b/vertex.go @@ -1,3 +1,5 @@ +//go:build !js + package glhf import ( From b30f28d254ab54ad6f4c22e7f4a8b52905c35df7 Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Mon, 20 Apr 2026 22:03:47 -0400 Subject: [PATCH 2/2] Gate desktop demo with !js tag, document WASM usage, add preprocessor test - examples/demo imports GLFW and broke GOOS=js GOARCH=wasm go build ./... (the advertised build test). Tag it !js so the example is desktop-only. - Document the SetContext / shader-preprocessor behavior under WebGL2 in the README and package doc so users building for the browser know the required init order and the GLSL 330 -> 300 es adaptation. - Add a unit test for preprocessShaderForES300. The rest of the WebGL2 backend still needs a real context to exercise, but the preprocessor is pure Go and worth pinning down. Co-Authored-By: Claude Opus 4.7 --- README.md | 21 +++++++++++++ doc.go | 7 ++++- examples/demo/main.go | 2 ++ glhf_wasm_test.go | 71 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 glhf_wasm_test.go diff --git a/README.md b/README.md index b3b963e..76b1c89 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,27 @@ for !shouldQuit { It uses OpenGL 3.3 and uses [`github.com/go-gl/gl/v3.3-core/gl`](https://github.com/go-gl/gl/tree/master/v3.3-core/gl). +### Does GLHF work in a browser (WebAssembly)? + +Yes. Under `GOOS=js GOARCH=wasm` GLHF dispatches to a WebGL2 rendering context +via `syscall/js` instead of go-gl. The exported API is the same, with one extra +step: before calling `glhf.Init` (or any `New*` / `Make*` constructor), you +must hand GLHF the `WebGL2RenderingContext` you created from your HTML canvas: + +```go +//go:build js && wasm + +gl := jsCanvas.Call("getContext", "webgl2") +glhf.SetContext(gl) +glhf.Init() +``` + +GLSL 330 core shader sources are transparently adapted to GLSL ES 3.00 (the +only version WebGL2 accepts). A few desktop-OpenGL concepts don't exist in +WebGL2 and are stubbed or substituted — notably `CLAMP_TO_BORDER` falls back to +`CLAMP_TO_EDGE`, and `glGetTexImage` is emulated through an off-screen +framebuffer + `readPixels`. + ### Why do I have to use `github.com/gopxl/mainthread` package with GLHF? First of all, OpenGL has to be done from one thread and many operating systems require, that the one diff --git a/doc.go b/doc.go index 885fd1b..17f2434 100644 --- a/doc.go +++ b/doc.go @@ -1,6 +1,11 @@ // Package glhf provides abstractions around the basic OpenGL primitives and operations. // -// All calls should be done from the main thread using "github.com/gopxl/mainthread/v2" package. +// On desktop targets all calls should be done from the main thread using the +// "github.com/gopxl/mainthread/v2" package. +// +// A parallel WebGL2 backend is built under GOOS=js GOARCH=wasm. The exported +// API is the same, except that the caller must install a WebGL2 rendering +// context once via SetContext before invoking Init or any constructor. // // This package deliberately does not handle nor report trivial OpenGL errors, it's up to you to // cause none. It does of course report errors like shader compilation error and such. diff --git a/examples/demo/main.go b/examples/demo/main.go index dbeed2c..15b904c 100644 --- a/examples/demo/main.go +++ b/examples/demo/main.go @@ -1,3 +1,5 @@ +//go:build !js + package main import ( diff --git a/glhf_wasm_test.go b/glhf_wasm_test.go new file mode 100644 index 0000000..79ab27e --- /dev/null +++ b/glhf_wasm_test.go @@ -0,0 +1,71 @@ +//go:build js && wasm + +package glhf + +import ( + "strings" + "testing" +) + +func TestPreprocessShaderForES300(t *testing.T) { + tests := []struct { + name string + src string + want []string // substrings that must be present in output + notWant []string // substrings that must NOT be present + }{ + { + name: "rewrites 330 core to 300 es", + src: "#version 330 core\nvoid main() {}\n", + want: []string{ + "#version 300 es", + "precision highp float;", + "precision highp int;", + "void main() {}", + }, + notWant: []string{"#version 330 core"}, + }, + { + name: "passes 300 es through unchanged", + src: "#version 300 es\nprecision mediump float;\nvoid main() {}\n", + want: []string{"#version 300 es", "precision mediump float;"}, + notWant: []string{"precision highp float;"}, + }, + { + name: "passes 310 es through unchanged", + src: "#version 310 es\nvoid main() {}\n", + want: []string{"#version 310 es"}, + notWant: []string{"precision highp float;"}, + }, + { + name: "injects header when no #version directive", + src: "void main() {}\n", + want: []string{ + "#version 300 es", + "precision highp float;", + "void main() {}", + }, + }, + { + name: "tolerates leading whitespace", + src: "\n\n #version 330 core\nvoid main() {}\n", + want: []string{"#version 300 es", "void main() {}"}, + notWant: []string{"#version 330 core"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := preprocessShaderForES300(tc.src) + for _, s := range tc.want { + if !strings.Contains(got, s) { + t.Errorf("output missing %q\ngot:\n%s", s, got) + } + } + for _, s := range tc.notWant { + if strings.Contains(got, s) { + t.Errorf("output should not contain %q\ngot:\n%s", s, got) + } + } + }) + } +}