From dc2eb1d45b919c35805d0dec848feb6a8f924693 Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Mon, 20 Apr 2026 21:44:38 -0400 Subject: [PATCH 01/11] Add WebAssembly (js/wasm) backend for the opengl package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a parallel WebGL2 backend under the `js && wasm` build tag so pixel can target the browser without touching desktop code paths. The existing GLFW + go-gl files are tagged `!js`; new `*_wasm.go` siblings implement Window, Canvas, input (keyboard/mouse via DOM events), and stubs for cursor/joystick/monitor. Also refactors a few desktop call sites off the raw `go-gl/gl` package and onto glhf wrappers (`BlendFuncSeparate`, `BlendEquation`, `ActiveTexture`) so the same source compiles for both targets. Two of pixel's internal shaders gained explicit float literals (`0.0`, `2.0`) so GLSL ES 300 — which is stricter about int->float conversion than 330 core — accepts them unchanged. Depends on companion WASM branches of glhf and mainthread. Desktop behavior is unchanged. Co-Authored-By: Claude Opus 4.7 --- backends/opengl/cursor.go | 2 + backends/opengl/cursor_wasm.go | 31 ++++ backends/opengl/glpicture.go | 15 ++ backends/opengl/glshader.go | 10 +- backends/opengl/input.go | 2 + backends/opengl/input_dom_wasm.go | 221 +++++++++++++++++++++++ backends/opengl/input_wasm.go | 51 ++++++ backends/opengl/joystick.go | 2 + backends/opengl/joystick_wasm.go | 17 ++ backends/opengl/monitor.go | 2 + backends/opengl/monitor_wasm.go | 32 ++++ backends/opengl/run.go | 2 + backends/opengl/run_wasm.go | 12 ++ backends/opengl/window.go | 2 + backends/opengl/window_wasm.go | 287 ++++++++++++++++++++++++++++++ 15 files changed, 683 insertions(+), 5 deletions(-) create mode 100644 backends/opengl/cursor_wasm.go create mode 100644 backends/opengl/input_dom_wasm.go create mode 100644 backends/opengl/input_wasm.go create mode 100644 backends/opengl/joystick_wasm.go create mode 100644 backends/opengl/monitor_wasm.go create mode 100644 backends/opengl/run_wasm.go create mode 100644 backends/opengl/window_wasm.go diff --git a/backends/opengl/cursor.go b/backends/opengl/cursor.go index 6dc7580..31802f8 100644 --- a/backends/opengl/cursor.go +++ b/backends/opengl/cursor.go @@ -1,3 +1,5 @@ +//go:build !js + package opengl import ( diff --git a/backends/opengl/cursor_wasm.go b/backends/opengl/cursor_wasm.go new file mode 100644 index 0000000..32fd31a --- /dev/null +++ b/backends/opengl/cursor_wasm.go @@ -0,0 +1,31 @@ +//go:build js && wasm + +package opengl + +import ( + "image" + + "github.com/gopxl/pixel/v2" +) + +// StandardCursor matches the desktop-side typed constant. Under WASM we have +// no real cursor API wired up, so the constants exist only so game code can +// reference them unchanged. +type StandardCursor int + +const ( + ArrowCursor StandardCursor = iota + IBeamCursor + CrosshairCursor + HandCursor + HResizeCursor + VResizeCursor +) + +// Cursor is an opaque handle. We keep the struct empty; the browser manages +// the mouse cursor itself. +type Cursor struct{} + +func CreateStandardCursor(StandardCursor) *Cursor { return &Cursor{} } +func CreateCursorImage(image.Image, pixel.Vec) *Cursor { return &Cursor{} } +func (w *Window) SetCursor(*Cursor) {} diff --git a/backends/opengl/glpicture.go b/backends/opengl/glpicture.go index 3f16504..b31dfad 100644 --- a/backends/opengl/glpicture.go +++ b/backends/opengl/glpicture.go @@ -2,6 +2,7 @@ package opengl import ( "math" + "sync" "github.com/gopxl/glhf/v2" "github.com/gopxl/mainthread/v2" @@ -18,9 +19,20 @@ type GLPicture interface { Texture() *glhf.Texture } +// pictureDataGLCache memoizes GLPicture uploads keyed by source *pixel.PictureData. +// Without this, every (sprite, canvas) pair re-uploads and retains a full-size pixel +// copy; on 32-bit WASM (~4GB heap) a 4096x4096 atlas blows the heap after a handful +// of draw targets. +var pictureDataGLCache sync.Map // *pixel.PictureData -> GLPicture + // NewGLPicture creates a new GLPicture with it's own static OpenGL texture. This function always // allocates a new texture that cannot (shouldn't) be further modified. func NewGLPicture(p pixel.Picture) GLPicture { + if pd, ok := p.(*pixel.PictureData); ok { + if cached, found := pictureDataGLCache.Load(pd); found { + return cached.(GLPicture) + } + } bounds := p.Bounds() bx, by, bw, bh := intBounds(bounds) @@ -65,6 +77,9 @@ func NewGLPicture(p pixel.Picture) GLPicture { tex: tex, pixels: pixels, } + if pd, ok := p.(*pixel.PictureData); ok { + pictureDataGLCache.Store(pd, gp) + } return gp } diff --git a/backends/opengl/glshader.go b/backends/opengl/glshader.go index 48b3d0e..27fddc3 100644 --- a/backends/opengl/glshader.go +++ b/backends/opengl/glshader.go @@ -256,7 +256,7 @@ uniform vec4 uBounds; void main() { vec2 transPos = (uTransform * vec3(aPosition, 1.0)).xy; - vec2 normPos = (transPos - uBounds.xy) / uBounds.zw * 2 - vec2(1, 1); + vec2 normPos = (transPos - uBounds.xy) / uBounds.zw * 2.0 - vec2(1.0, 1.0); gl_Position = vec4(normPos, 0.0, 1.0); vColor = aColor; @@ -282,14 +282,14 @@ uniform vec4 uTexBounds; uniform sampler2D uTexture; void main() { - if ((vClipRect != vec4(0,0,0,0)) && (gl_FragCoord.x < vClipRect.x || gl_FragCoord.y < vClipRect.y || gl_FragCoord.x > vClipRect.z || gl_FragCoord.y > vClipRect.w)) + if ((vClipRect != vec4(0.0, 0.0, 0.0, 0.0)) && (gl_FragCoord.x < vClipRect.x || gl_FragCoord.y < vClipRect.y || gl_FragCoord.x > vClipRect.z || gl_FragCoord.y > vClipRect.w)) discard; - if (vIntensity == 0) { + if (vIntensity == 0.0) { fragColor = uColorMask * vColor; } else { - fragColor = vec4(0, 0, 0, 0); - fragColor += (1 - vIntensity) * vColor; + fragColor = vec4(0.0, 0.0, 0.0, 0.0); + fragColor += (1.0 - vIntensity) * vColor; vec2 t = (vTexCoords - uTexBounds.xy) / uTexBounds.zw; fragColor += vIntensity * vColor * texture(uTexture, t); fragColor *= uColorMask; diff --git a/backends/opengl/input.go b/backends/opengl/input.go index dffd95d..8bb2ca5 100644 --- a/backends/opengl/input.go +++ b/backends/opengl/input.go @@ -1,3 +1,5 @@ +//go:build !js + package opengl import ( diff --git a/backends/opengl/input_dom_wasm.go b/backends/opengl/input_dom_wasm.go new file mode 100644 index 0000000..3f62a95 --- /dev/null +++ b/backends/opengl/input_dom_wasm.go @@ -0,0 +1,221 @@ +//go:build js && wasm + +package opengl + +import ( + "strings" + "syscall/js" + + "github.com/gopxl/pixel/v2" +) + +// domCodeToButton maps KeyboardEvent.code values to pixel.Button constants. +// Only keys the game consumes are mapped; unknown codes yield ok=false. +var domCodeToButton = map[string]pixel.Button{ + "Space": pixel.KeySpace, + "Quote": pixel.KeyApostrophe, + "Comma": pixel.KeyComma, + "Minus": pixel.KeyMinus, + "Period": pixel.KeyPeriod, + "Slash": pixel.KeySlash, + "Digit0": pixel.Key0, + "Digit1": pixel.Key1, + "Digit2": pixel.Key2, + "Digit3": pixel.Key3, + "Digit4": pixel.Key4, + "Digit5": pixel.Key5, + "Digit6": pixel.Key6, + "Digit7": pixel.Key7, + "Digit8": pixel.Key8, + "Digit9": pixel.Key9, + "Semicolon": pixel.KeySemicolon, + "Equal": pixel.KeyEqual, + "KeyA": pixel.KeyA, + "KeyB": pixel.KeyB, + "KeyC": pixel.KeyC, + "KeyD": pixel.KeyD, + "KeyE": pixel.KeyE, + "KeyF": pixel.KeyF, + "KeyG": pixel.KeyG, + "KeyH": pixel.KeyH, + "KeyI": pixel.KeyI, + "KeyJ": pixel.KeyJ, + "KeyK": pixel.KeyK, + "KeyL": pixel.KeyL, + "KeyM": pixel.KeyM, + "KeyN": pixel.KeyN, + "KeyO": pixel.KeyO, + "KeyP": pixel.KeyP, + "KeyQ": pixel.KeyQ, + "KeyR": pixel.KeyR, + "KeyS": pixel.KeyS, + "KeyT": pixel.KeyT, + "KeyU": pixel.KeyU, + "KeyV": pixel.KeyV, + "KeyW": pixel.KeyW, + "KeyX": pixel.KeyX, + "KeyY": pixel.KeyY, + "KeyZ": pixel.KeyZ, + "BracketLeft": pixel.KeyLeftBracket, + "Backslash": pixel.KeyBackslash, + "BracketRight": pixel.KeyRightBracket, + "Backquote": pixel.KeyGraveAccent, + "Escape": pixel.KeyEscape, + "Enter": pixel.KeyEnter, + "Tab": pixel.KeyTab, + "Backspace": pixel.KeyBackspace, + "Insert": pixel.KeyInsert, + "Delete": pixel.KeyDelete, + "ArrowRight": pixel.KeyRight, + "ArrowLeft": pixel.KeyLeft, + "ArrowDown": pixel.KeyDown, + "ArrowUp": pixel.KeyUp, + "PageUp": pixel.KeyPageUp, + "PageDown": pixel.KeyPageDown, + "Home": pixel.KeyHome, + "End": pixel.KeyEnd, + "CapsLock": pixel.KeyCapsLock, + "ScrollLock": pixel.KeyScrollLock, + "NumLock": pixel.KeyNumLock, + "PrintScreen": pixel.KeyPrintScreen, + "Pause": pixel.KeyPause, + "F1": pixel.KeyF1, + "F2": pixel.KeyF2, + "F3": pixel.KeyF3, + "F4": pixel.KeyF4, + "F5": pixel.KeyF5, + "F6": pixel.KeyF6, + "F7": pixel.KeyF7, + "F8": pixel.KeyF8, + "F9": pixel.KeyF9, + "F10": pixel.KeyF10, + "F11": pixel.KeyF11, + "F12": pixel.KeyF12, + "Numpad0": pixel.KeyKP0, + "Numpad1": pixel.KeyKP1, + "Numpad2": pixel.KeyKP2, + "Numpad3": pixel.KeyKP3, + "Numpad4": pixel.KeyKP4, + "Numpad5": pixel.KeyKP5, + "Numpad6": pixel.KeyKP6, + "Numpad7": pixel.KeyKP7, + "Numpad8": pixel.KeyKP8, + "Numpad9": pixel.KeyKP9, + "NumpadDecimal": pixel.KeyKPDecimal, + "NumpadDivide": pixel.KeyKPDivide, + "NumpadMultiply": pixel.KeyKPMultiply, + "NumpadSubtract": pixel.KeyKPSubtract, + "NumpadAdd": pixel.KeyKPAdd, + "NumpadEnter": pixel.KeyKPEnter, + "NumpadEqual": pixel.KeyKPEqual, + "ShiftLeft": pixel.KeyLeftShift, + "ControlLeft": pixel.KeyLeftControl, + "AltLeft": pixel.KeyLeftAlt, + "MetaLeft": pixel.KeyLeftSuper, + "ShiftRight": pixel.KeyRightShift, + "ControlRight": pixel.KeyRightControl, + "AltRight": pixel.KeyRightAlt, + "MetaRight": pixel.KeyRightSuper, + "ContextMenu": pixel.KeyMenu, +} + +// initInput installs DOM keyboard listeners on the canvas. Events are +// translated into pixel.Button press/release/repeat on the shared +// InputHandler; printable characters are appended to the Typed buffer. +func (w *Window) initInput() { + keyDown := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + ev := args[0] + code := ev.Get("code").String() + btn, ok := domCodeToButton[code] + if !ok { + return nil + } + + if ev.Get("repeat").Bool() { + w.input.ButtonEvent(btn, pixel.Repeat) + } else { + w.input.ButtonEvent(btn, pixel.Press) + } + + // Feed printable characters into Typed buffer. Browsers give us the + // localized string in `event.key`; only single-code-point values + // correspond to real characters (others are "Enter", "Shift", etc). + key := ev.Get("key").String() + if r, ok := singleRune(key); ok { + w.input.CharEvent(r) + } + + // Swallow default handling for game-consumed keys so the browser + // doesn't scroll/tab-navigate while the canvas has focus. + if shouldPreventDefault(code) { + ev.Call("preventDefault") + } + return nil + }) + + keyUp := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + ev := args[0] + code := ev.Get("code").String() + btn, ok := domCodeToButton[code] + if !ok { + return nil + } + w.input.ButtonEvent(btn, pixel.Release) + if shouldPreventDefault(code) { + ev.Call("preventDefault") + } + return nil + }) + + // Blur clears all held keys so we don't get stuck-down buttons when the + // tab loses focus mid-keypress. + blur := js.FuncOf(func(this js.Value, args []js.Value) any { + for _, btn := range domCodeToButton { + w.input.ButtonEvent(btn, pixel.Release) + } + return nil + }) + + w.jsCanvas.Call("addEventListener", "keydown", keyDown) + w.jsCanvas.Call("addEventListener", "keyup", keyUp) + w.jsCanvas.Call("addEventListener", "blur", blur) +} + +func singleRune(s string) (rune, bool) { + if s == "" { + return 0, false + } + runes := []rune(s) + if len(runes) != 1 { + return 0, false + } + r := runes[0] + if r < 0x20 || r == 0x7f { + return 0, false + } + return r, true +} + +// shouldPreventDefault reports whether the browser's default handling of a +// key should be suppressed while the canvas has focus. +func shouldPreventDefault(code string) bool { + switch code { + case "Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", + "Tab", "Backspace", "Slash", "Quote": + return true + } + if strings.HasPrefix(code, "F") && len(code) > 1 { + // Let F12 (devtools) through; block other F-keys. + if code == "F12" { + return false + } + return true + } + return false +} diff --git a/backends/opengl/input_wasm.go b/backends/opengl/input_wasm.go new file mode 100644 index 0000000..b3cf605 --- /dev/null +++ b/backends/opengl/input_wasm.go @@ -0,0 +1,51 @@ +//go:build js && wasm + +package opengl + +import ( + "time" + + "github.com/gopxl/pixel/v2" + "github.com/gopxl/pixel/v2/backends/internal" +) + +// Input state is tracked via the same InputHandler the desktop backend uses; +// DOM event listeners (installed by Window.initInput in M4) feed it through +// ButtonEvent/CharEvent. Until then the listeners are empty and every query +// reports "not pressed". + +func (w *Window) Pressed(button pixel.Button) bool { return w.input.Curr.Buttons[button] } +func (w *Window) JustPressed(button pixel.Button) bool { return w.input.PressEvents[button] } +func (w *Window) JustReleased(button pixel.Button) bool { return w.input.ReleaseEvents[button] } +func (w *Window) Repeated(button pixel.Button) bool { return w.input.Curr.Repeat[button] } + +func (w *Window) MousePosition() pixel.Vec { return w.input.Curr.Mouse } +func (w *Window) MousePreviousPosition() pixel.Vec { return w.input.Prev.Mouse } +func (w *Window) SetMousePosition(v pixel.Vec) { w.input.SetMousePosition(v) } +func (w *Window) MouseInsideWindow() bool { return w.input.MouseInsideWindow } +func (w *Window) MouseScroll() pixel.Vec { return w.input.Curr.Scroll } +func (w *Window) MousePreviousScroll() pixel.Vec { return w.input.Prev.Scroll } +func (w *Window) Typed() string { return w.input.Curr.Typed } + +// SetButtonCallback / SetCharCallback / mouse callbacks exist so game code +// compiles unchanged; the WASM backend does not fire them yet. +func (w *Window) SetButtonCallback(cb func(win *Window, button pixel.Button, action pixel.Action)) { + w.buttonCallback = cb +} +func (w *Window) SetCharCallback(cb func(win *Window, r rune)) { w.charCallback = cb } +func (w *Window) SetMouseEnteredCallback(cb func(win *Window, entered bool)) { + w.mouseEnteredCallback = cb +} +func (w *Window) SetMouseMovedCallback(cb func(win *Window, pos pixel.Vec)) { + w.mouseMovedCallback = cb +} +func (w *Window) SetScrollCallback(cb func(win *Window, scroll pixel.Vec)) { + w.scrollCallback = cb +} + +// UpdateInput commits the pending input events for the next frame. +func (w *Window) UpdateInput() { w.input.Update() } +func (w *Window) UpdateInputWait(timeout time.Duration) { w.input.Update() } + +// Ensure internal package is referenced to avoid unused-import lint. +var _ = internal.InputHandler{} diff --git a/backends/opengl/joystick.go b/backends/opengl/joystick.go index 8043dbc..84e66c5 100644 --- a/backends/opengl/joystick.go +++ b/backends/opengl/joystick.go @@ -1,3 +1,5 @@ +//go:build !js + package opengl import ( diff --git a/backends/opengl/joystick_wasm.go b/backends/opengl/joystick_wasm.go new file mode 100644 index 0000000..8272032 --- /dev/null +++ b/backends/opengl/joystick_wasm.go @@ -0,0 +1,17 @@ +//go:build js && wasm + +package opengl + +import "github.com/gopxl/pixel/v2" + +// Gamepads are out of scope for the WASM backend; all queries report +// "not connected". + +func (w *Window) JoystickPresent(pixel.Joystick) bool { return false } +func (w *Window) JoystickName(pixel.Joystick) string { return "" } +func (w *Window) JoystickButtonCount(pixel.Joystick) int { return 0 } +func (w *Window) JoystickAxisCount(pixel.Joystick) int { return 0 } +func (w *Window) JoystickPressed(pixel.Joystick, pixel.GamepadButton) bool { return false } +func (w *Window) JoystickJustPressed(pixel.Joystick, pixel.GamepadButton) bool { return false } +func (w *Window) JoystickJustReleased(pixel.Joystick, pixel.GamepadButton) bool { return false } +func (w *Window) JoystickAxis(pixel.Joystick, pixel.GamepadAxis) float64 { return 0 } diff --git a/backends/opengl/monitor.go b/backends/opengl/monitor.go index 8580bc6..3f7b878 100644 --- a/backends/opengl/monitor.go +++ b/backends/opengl/monitor.go @@ -1,3 +1,5 @@ +//go:build !js + package opengl import ( diff --git a/backends/opengl/monitor_wasm.go b/backends/opengl/monitor_wasm.go new file mode 100644 index 0000000..971ef86 --- /dev/null +++ b/backends/opengl/monitor_wasm.go @@ -0,0 +1,32 @@ +//go:build js && wasm + +package opengl + +// Monitor represents a display. Under WASM we only ever have one logical +// monitor (the browser canvas host), so these are mostly stubs. +type Monitor struct{} + +// VideoMode mirrors the desktop shape. +type VideoMode struct { + Width int + Height int + RefreshRate int +} + +func PrimaryMonitor() *Monitor { return &Monitor{} } +func Monitors() []*Monitor { return []*Monitor{{}} } +func (m *Monitor) Name() string { return "Browser" } +func (m *Monitor) PhysicalSize() (width, height float64) { + return 0, 0 +} +func (m *Monitor) Position() (x, y float64) { return 0, 0 } +func (m *Monitor) Size() (width, height float64) { + return 0, 0 +} +func (m *Monitor) BitDepth() (red, green, blue int) { + return 8, 8, 8 +} +func (m *Monitor) RefreshRate() (rate float64) { return 60 } +func (m *Monitor) VideoModes() (vmodes []VideoMode) { + return nil +} diff --git a/backends/opengl/run.go b/backends/opengl/run.go index a11cb99..5be0178 100644 --- a/backends/opengl/run.go +++ b/backends/opengl/run.go @@ -1,3 +1,5 @@ +//go:build !js + package opengl import ( diff --git a/backends/opengl/run_wasm.go b/backends/opengl/run_wasm.go new file mode 100644 index 0000000..ba6a8bf --- /dev/null +++ b/backends/opengl/run_wasm.go @@ -0,0 +1,12 @@ +//go:build js && wasm + +package opengl + +import "github.com/gopxl/mainthread/v2" + +// Run invokes the supplied function on the JS event-loop thread. The WASM +// mainthread shim calls the function inline, so this is a thin pass-through +// that keeps the call-site compatible with the desktop backend. +func Run(run func()) { + mainthread.Run(run) +} diff --git a/backends/opengl/window.go b/backends/opengl/window.go index efa0108..15b652b 100644 --- a/backends/opengl/window.go +++ b/backends/opengl/window.go @@ -1,3 +1,5 @@ +//go:build !js + package opengl import ( diff --git a/backends/opengl/window_wasm.go b/backends/opengl/window_wasm.go new file mode 100644 index 0000000..6f5fd56 --- /dev/null +++ b/backends/opengl/window_wasm.go @@ -0,0 +1,287 @@ +//go:build js && wasm + +package opengl + +import ( + "image/color" + "syscall/js" + + "github.com/gopxl/glhf/v2" + "github.com/gopxl/pixel/v2" + "github.com/gopxl/pixel/v2/backends/internal" + "github.com/pkg/errors" +) + +// CanvasElementID names the HTML canvas the WASM backend will attach to. +// Override before calling NewWindow to target a different element. +var CanvasElementID = "game" + +// WindowConfig mirrors the desktop struct so call-sites compile unchanged. +// Most fields are no-ops under WASM; only Title and Bounds are honored. +type WindowConfig struct { + Title string + Icon []pixel.Picture + Bounds pixel.Rect + Position pixel.Vec + Monitor *Monitor + Resizable bool + Undecorated bool + NoIconify bool + AlwaysOnTop bool + TransparentFramebuffer bool + VSync bool + Maximized bool + Invisible bool + SamplesMSAA int + BoundsLimits pixel.Rect +} + +// Window wraps an HTML5 canvas plus a WebGL2 rendering context. The internal +// Canvas handles all drawing; Update blits it to the default framebuffer and +// yields to requestAnimationFrame. +type Window struct { + bounds pixel.Rect + canvas *Canvas + + jsCanvas js.Value + gl js.Value + + closed bool + vsync bool + cursorVisible bool + + input internal.InputHandler + + buttonCallback func(win *Window, button pixel.Button, action pixel.Action) + charCallback func(win *Window, r rune) + mouseEnteredCallback func(win *Window, entered bool) + mouseMovedCallback func(win *Window, pos pixel.Vec) + scrollCallback func(win *Window, scroll pixel.Vec) +} + +var currWin *Window + +// NewWindow binds to the configured HTML canvas, creates a WebGL2 context, +// and initializes glhf. Returns an error if the canvas or context is missing. +func NewWindow(cfg WindowConfig) (*Window, error) { + doc := js.Global().Get("document") + jsCanvas := doc.Call("getElementById", CanvasElementID) + if !jsCanvas.Truthy() { + return nil, errors.Errorf("canvas #%s not found", CanvasElementID) + } + + gl := jsCanvas.Call("getContext", "webgl2", map[string]any{ + "alpha": false, + "antialias": cfg.SamplesMSAA > 0, + "premultipliedAlpha": true, + "preserveDrawingBuffer": false, + }) + if !gl.Truthy() { + return nil, errors.New("webgl2 not available") + } + + _, _, w, h := intBounds(cfg.Bounds) + jsCanvas.Set("width", w) + jsCanvas.Set("height", h) + + if cfg.Title != "" { + doc.Set("title", cfg.Title) + } + + glhf.SetContext(gl) + glhf.Init() + + win := &Window{ + bounds: cfg.Bounds, + jsCanvas: jsCanvas, + gl: gl, + vsync: cfg.VSync, + cursorVisible: true, + } + + win.canvas = NewCanvas(cfg.Bounds) + currWin = win + + // Ensure the canvas can receive keyboard focus inside iframes. + if jsCanvas.Get("tabIndex").Int() < 0 { + jsCanvas.Set("tabIndex", 0) + } + jsCanvas.Call("focus") + + win.initInput() + win.installContextLostHandler() + + return win, nil +} + +// installContextLostHandler logs and full-page-reloads on WebGL context loss. +// True in-place recovery is out of scope; reload is the simplest safe action. +func (w *Window) installContextLostHandler() { + lost := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) > 0 { + args[0].Call("preventDefault") + } + js.Global().Get("console").Call("warn", "webglcontextlost — reloading") + js.Global().Get("location").Call("reload") + return nil + }) + w.jsCanvas.Call("addEventListener", "webglcontextlost", lost) +} + +// Destroy releases the WebGL context. +func (w *Window) Destroy() { + w.closed = true +} + +// Update blits the internal canvas to the default framebuffer and yields to +// the browser's requestAnimationFrame so the next frame can be scheduled. +func (w *Window) Update() { + w.syncCanvasSize() + w.SwapBuffers() + w.UpdateInput() + awaitAnimationFrame() +} + +// syncCanvasSize updates the canvas backing store to match its current CSS size +// (times devicePixelRatio). Call before each frame so window resizes and +// fullscreen transitions propagate into window.Bounds() without a JS callback. +func (w *Window) syncCanvasSize() { + cssW := w.jsCanvas.Get("clientWidth").Int() + cssH := w.jsCanvas.Get("clientHeight").Int() + if cssW <= 0 || cssH <= 0 { + return + } + dpr := js.Global().Get("devicePixelRatio").Float() + if dpr < 1 { + dpr = 1 + } + targetW := int(float64(cssW) * dpr) + targetH := int(float64(cssH) * dpr) + curW := w.jsCanvas.Get("width").Int() + curH := w.jsCanvas.Get("height").Int() + if curW == targetW && curH == targetH { + return + } + w.jsCanvas.Set("width", targetW) + w.jsCanvas.Set("height", targetH) + w.bounds = pixel.R(0, 0, float64(targetW), float64(targetH)) + w.canvas.SetBounds(w.bounds) +} + +// SwapBuffers copies the current canvas texture into the default framebuffer. +func (w *Window) SwapBuffers() { + fbW, fbH := w.framebufferSize() + glhf.Bounds(0, 0, fbW, fbH) + glhf.Clear(0, 0, 0, 0) + + w.canvas.gf.Frame().Begin() + w.canvas.gf.Frame().Blit( + nil, + 0, 0, w.canvas.Texture().Width(), w.canvas.Texture().Height(), + 0, 0, fbW, fbH, + ) + w.canvas.gf.Frame().End() +} + +func (w *Window) framebufferSize() (int, int) { + return w.jsCanvas.Get("width").Int(), w.jsCanvas.Get("height").Int() +} + +// awaitAnimationFrame blocks the calling goroutine until the browser fires +// the next rAF callback. This is how the WASM backend yields time to the +// JS event loop once per simulated frame. +func awaitAnimationFrame() { + ch := make(chan struct{}, 1) + var cb js.Func + cb = js.FuncOf(func(this js.Value, args []js.Value) any { + cb.Release() + ch <- struct{}{} + return nil + }) + js.Global().Call("requestAnimationFrame", cb) + <-ch +} + +// Closed reports whether the window has been explicitly closed. The browser +// tab closing terminates the Go runtime, so this only reflects SetClosed. +func (w *Window) Closed() bool { return w.closed } +func (w *Window) SetClosed(closed bool) { w.closed = closed } + +func (w *Window) Bounds() pixel.Rect { return w.bounds } +func (w *Window) SetBounds(bounds pixel.Rect) { + w.bounds = bounds + _, _, width, height := intBounds(bounds) + w.jsCanvas.Set("width", width) + w.jsCanvas.Set("height", height) + w.canvas.SetBounds(bounds) +} +func (w *Window) SetBoundsLimits(pixel.Rect) {} + +func (w *Window) SetPos(pixel.Vec) {} +func (w *Window) GetPos() pixel.Vec { return pixel.ZV } +func (w *Window) SetTitle(title string) { + js.Global().Get("document").Set("title", title) +} + +func (w *Window) Focused() bool { + return js.Global().Get("document").Call("hasFocus").Bool() +} + +func (w *Window) SetVSync(vsync bool) { w.vsync = vsync } +func (w *Window) VSync() bool { return w.vsync } + +func (w *Window) SetCursorVisible(visible bool) { + w.cursorVisible = visible + style := w.jsCanvas.Get("style") + if visible { + style.Set("cursor", "auto") + } else { + style.Set("cursor", "none") + } +} +func (w *Window) SetCursorDisabled() { + w.cursorVisible = false + w.jsCanvas.Get("style").Set("cursor", "none") +} +func (w *Window) CursorVisible() bool { return w.cursorVisible } + +func (w *Window) SetMonitor(*Monitor) {} +func (w *Window) Monitor() *Monitor { return nil } + +// Note: must be called before any GL work. Kept for parity with desktop. +func (w *Window) begin() { + if currWin != w { + currWin = w + } +} +func (w *Window) end() {} + +// Drawing delegation ------------------------------------------------------- + +func (w *Window) MakeTriangles(t pixel.Triangles) pixel.TargetTriangles { + return w.canvas.MakeTriangles(t) +} +func (w *Window) MakePicture(p pixel.Picture) pixel.TargetPicture { + return w.canvas.MakePicture(p) +} +func (w *Window) SetMatrix(m pixel.Matrix) { w.canvas.SetMatrix(m) } +func (w *Window) SetColorMask(c color.Color) { w.canvas.SetColorMask(c) } +func (w *Window) SetComposeMethod(cmp pixel.ComposeMethod) { w.canvas.SetComposeMethod(cmp) } +func (w *Window) SetSmooth(smooth bool) { w.canvas.SetSmooth(smooth) } +func (w *Window) Smooth() bool { return w.canvas.Smooth() } +func (w *Window) Clear(c color.Color) { w.canvas.Clear(c) } +func (w *Window) Color(at pixel.Vec) pixel.RGBA { return w.canvas.Color(at) } +func (w *Window) Canvas() *Canvas { return w.canvas } + +// Show/Hide/Focus are no-ops in a browser environment. +func (w *Window) Show() {} +func (w *Window) Hide() {} +func (w *Window) Focus() { w.jsCanvas.Call("focus") } + +// Clipboard access goes through the browser's clipboard API when available. +// For now we return empty strings; writes are best-effort and ignored when +// the API is unavailable (e.g. insecure origin). +func (w *Window) ClipboardText() string { return "" } +func (w *Window) SetClipboardText(text string) {} +func (w *Window) Clipboard() string { return "" } +func (w *Window) SetClipboard(str string) {} From 22d42a88b321124f4c5b1d5cca16966d0f87d639 Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Mon, 20 Apr 2026 22:03:57 -0400 Subject: [PATCH 02/11] Wire DOM mouse events, refresh WASM docs, drop dead import shim The PR description claimed mouse input was wired through DOM events, but input_dom_wasm.go only installed keyboard listeners. Add mousedown / mouseup / mousemove / mouseenter / mouseleave / wheel / contextmenu handlers, map MouseEvent.button to pixel.MouseButtonN, and fire all user-registered callbacks (button, char, mouse moved/entered, scroll) so game code that sets them sees the same events as on desktop. Also drop the unused internal.InputHandler{} shim and its stale "not wired up yet" comment, and add a WebAssembly section to the top-level README describing how to load the canvas, which features are stubbed, and the required build command. Adjust the "Missing features" list so the HTML5 backend isn't still called out as missing. Co-Authored-By: Claude Opus 4.7 --- README.md | 23 ++++- backends/opengl/input_dom_wasm.go | 146 +++++++++++++++++++++++++++++- backends/opengl/input_wasm.go | 17 ++-- 3 files changed, 172 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6fbec63..88f927b 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Pixel is in development and still missing few critical features. Here're the mos - Antialiasing (filtering is supported, though) - ~~Advanced window manipulation (cursor hiding, window icon, ...)~~ - Better support for Hi-DPI displays -- Mobile (and perhaps HTML5?) backend +- Mobile backend (an HTML5/WebGL2 backend ships under `GOOS=js GOARCH=wasm`) - ~~More advanced graphical effects (e.g. blur)~~ (solved with the addition of GLSL effects) - Tests and benchmarks - Vulkan support @@ -134,6 +134,27 @@ Pixel is in development and still missing few critical features. Here're the mos **Implementing these features will get us to the 1.0 release.** Contribute, so that it's as soon as possible! +## WebAssembly (browser) + +Pixel ships a WebGL2 backend under the `js && wasm` build tag, so a game +written against the standard `opengl` backend can also be compiled for the +browser: + +```sh +GOOS=js GOARCH=wasm go build -o game.wasm ./cmd/game +``` + +Load `game.wasm` from an HTML page that also serves Go's `wasm_exec.js` and +provides a canvas element. The backend attaches to `` by +default; override by setting `opengl.CanvasElementID` before calling +`opengl.NewWindow`. + +Some desktop-oriented features are stubbed in the browser: multi-monitor +queries, joysticks/gamepads, and custom cursor images are no-ops, and window +positioning is driven by CSS rather than by `SetPos`. Keyboard, mouse (buttons, +movement, scroll), text input, and resizing (including fullscreen) are wired +up through DOM events. + ## Requirements If you're using Windows and having trouble building Pixel, please check [this guide](./docs/Compilation/Building-Pixel-on-Windows.md) on the [wiki](./docs/README.md). diff --git a/backends/opengl/input_dom_wasm.go b/backends/opengl/input_dom_wasm.go index 3f62a95..ba1c32a 100644 --- a/backends/opengl/input_dom_wasm.go +++ b/backends/opengl/input_dom_wasm.go @@ -119,9 +119,20 @@ var domCodeToButton = map[string]pixel.Button{ "ContextMenu": pixel.KeyMenu, } -// initInput installs DOM keyboard listeners on the canvas. Events are -// translated into pixel.Button press/release/repeat on the shared -// InputHandler; printable characters are appended to the Typed buffer. +// domMouseButton maps MouseEvent.button to the pixel.Button constants. +// Unmapped values yield ok=false. +var domMouseButton = map[int]pixel.Button{ + 0: pixel.MouseButton1, // left + 1: pixel.MouseButton3, // middle + 2: pixel.MouseButton2, // right + 3: pixel.MouseButton4, // back + 4: pixel.MouseButton5, // forward +} + +// initInput installs DOM keyboard and mouse listeners on the canvas. Events +// are translated into pixel.Button press/release/repeat events and mouse +// move/scroll events on the shared InputHandler; printable characters are +// appended to the Typed buffer. func (w *Window) initInput() { keyDown := js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) == 0 { @@ -136,8 +147,10 @@ func (w *Window) initInput() { if ev.Get("repeat").Bool() { w.input.ButtonEvent(btn, pixel.Repeat) + w.fireButtonCallback(btn, pixel.Repeat) } else { w.input.ButtonEvent(btn, pixel.Press) + w.fireButtonCallback(btn, pixel.Press) } // Feed printable characters into Typed buffer. Browsers give us the @@ -146,6 +159,9 @@ func (w *Window) initInput() { key := ev.Get("key").String() if r, ok := singleRune(key); ok { w.input.CharEvent(r) + if w.charCallback != nil { + w.charCallback(w, r) + } } // Swallow default handling for game-consumed keys so the browser @@ -167,6 +183,7 @@ func (w *Window) initInput() { return nil } w.input.ButtonEvent(btn, pixel.Release) + w.fireButtonCallback(btn, pixel.Release) if shouldPreventDefault(code) { ev.Call("preventDefault") } @@ -179,12 +196,135 @@ func (w *Window) initInput() { for _, btn := range domCodeToButton { w.input.ButtonEvent(btn, pixel.Release) } + for _, btn := range domMouseButton { + w.input.ButtonEvent(btn, pixel.Release) + } + return nil + }) + + mouseDown := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + ev := args[0] + btn, ok := domMouseButton[ev.Get("button").Int()] + if !ok { + return nil + } + // Prevent right-click context menu and middle-click autoscroll + // while the canvas has focus. + ev.Call("preventDefault") + w.input.ButtonEvent(btn, pixel.Press) + w.fireButtonCallback(btn, pixel.Press) + // Ensure subsequent keydown events continue to land on the canvas. + w.jsCanvas.Call("focus") + return nil + }) + + mouseUp := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + ev := args[0] + btn, ok := domMouseButton[ev.Get("button").Int()] + if !ok { + return nil + } + w.input.ButtonEvent(btn, pixel.Release) + w.fireButtonCallback(btn, pixel.Release) + return nil + }) + + mouseMove := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + pos := w.mousePosFromEvent(args[0]) + w.input.MouseMoveEvent(pos) + if w.mouseMovedCallback != nil { + w.mouseMovedCallback(w, pos) + } + return nil + }) + + mouseEnter := js.FuncOf(func(this js.Value, args []js.Value) any { + w.input.MouseEnteredEvent(true) + if w.mouseEnteredCallback != nil { + w.mouseEnteredCallback(w, true) + } + return nil + }) + + mouseLeave := js.FuncOf(func(this js.Value, args []js.Value) any { + w.input.MouseEnteredEvent(false) + if w.mouseEnteredCallback != nil { + w.mouseEnteredCallback(w, false) + } + return nil + }) + + wheel := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + ev := args[0] + ev.Call("preventDefault") + // Browsers deliver wheel deltas in pixels (deltaMode=0) or lines/ + // pages; we forward the raw pixel delta and let the caller scale it. + // Y is inverted so scrolling up yields a positive value, matching + // the desktop backend. + dx := ev.Get("deltaX").Float() + dy := -ev.Get("deltaY").Float() + w.input.MouseScrollEvent(dx, dy) + if w.scrollCallback != nil { + w.scrollCallback(w, pixel.V(dx, dy)) + } + return nil + }) + + contextMenu := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) > 0 { + args[0].Call("preventDefault") + } return nil }) w.jsCanvas.Call("addEventListener", "keydown", keyDown) w.jsCanvas.Call("addEventListener", "keyup", keyUp) w.jsCanvas.Call("addEventListener", "blur", blur) + w.jsCanvas.Call("addEventListener", "mousedown", mouseDown) + w.jsCanvas.Call("addEventListener", "mouseup", mouseUp) + w.jsCanvas.Call("addEventListener", "mousemove", mouseMove) + w.jsCanvas.Call("addEventListener", "mouseenter", mouseEnter) + w.jsCanvas.Call("addEventListener", "mouseleave", mouseLeave) + w.jsCanvas.Call("addEventListener", "wheel", wheel, map[string]any{"passive": false}) + w.jsCanvas.Call("addEventListener", "contextmenu", contextMenu) +} + +// mousePosFromEvent converts a MouseEvent's clientX/Y (in CSS pixels, +// relative to the viewport) into window-local coordinates in the backing +// store's pixel space. Y is flipped so the origin is at the bottom-left, +// matching the desktop backend. +func (w *Window) mousePosFromEvent(ev js.Value) pixel.Vec { + rect := w.jsCanvas.Call("getBoundingClientRect") + cssX := ev.Get("clientX").Float() - rect.Get("left").Float() + cssY := ev.Get("clientY").Float() - rect.Get("top").Float() + cssW := rect.Get("width").Float() + cssH := rect.Get("height").Float() + if cssW <= 0 || cssH <= 0 { + return pixel.ZV + } + bounds := w.bounds + x := bounds.Min.X + (cssX/cssW)*bounds.W() + y := bounds.Min.Y + (1-cssY/cssH)*bounds.H() + return pixel.V(x, y) +} + +// fireButtonCallback invokes the user-registered button callback if any. +func (w *Window) fireButtonCallback(btn pixel.Button, action pixel.Action) { + if w.buttonCallback != nil { + w.buttonCallback(w, btn, action) + } } func singleRune(s string) (rune, bool) { diff --git a/backends/opengl/input_wasm.go b/backends/opengl/input_wasm.go index b3cf605..68a2919 100644 --- a/backends/opengl/input_wasm.go +++ b/backends/opengl/input_wasm.go @@ -6,13 +6,11 @@ import ( "time" "github.com/gopxl/pixel/v2" - "github.com/gopxl/pixel/v2/backends/internal" ) // Input state is tracked via the same InputHandler the desktop backend uses; -// DOM event listeners (installed by Window.initInput in M4) feed it through -// ButtonEvent/CharEvent. Until then the listeners are empty and every query -// reports "not pressed". +// DOM event listeners installed by Window.initInput feed it through +// ButtonEvent / CharEvent / MouseMoveEvent / MouseScrollEvent. func (w *Window) Pressed(button pixel.Button) bool { return w.input.Curr.Buttons[button] } func (w *Window) JustPressed(button pixel.Button) bool { return w.input.PressEvents[button] } @@ -27,8 +25,6 @@ func (w *Window) MouseScroll() pixel.Vec { return w.input.Curr.Scroll func (w *Window) MousePreviousScroll() pixel.Vec { return w.input.Prev.Scroll } func (w *Window) Typed() string { return w.input.Curr.Typed } -// SetButtonCallback / SetCharCallback / mouse callbacks exist so game code -// compiles unchanged; the WASM backend does not fire them yet. func (w *Window) SetButtonCallback(cb func(win *Window, button pixel.Button, action pixel.Action)) { w.buttonCallback = cb } @@ -44,8 +40,9 @@ func (w *Window) SetScrollCallback(cb func(win *Window, scroll pixel.Vec)) { } // UpdateInput commits the pending input events for the next frame. -func (w *Window) UpdateInput() { w.input.Update() } -func (w *Window) UpdateInputWait(timeout time.Duration) { w.input.Update() } +func (w *Window) UpdateInput() { w.input.Update() } -// Ensure internal package is referenced to avoid unused-import lint. -var _ = internal.InputHandler{} +// UpdateInputWait commits pending events. The timeout argument is accepted for +// API parity with the desktop backend but ignored under WASM — the browser +// event loop delivers events asynchronously. +func (w *Window) UpdateInputWait(timeout time.Duration) { w.input.Update() } From 399d77dd8d1b90aed1c4fdadef6724c7159f2149 Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Mon, 20 Apr 2026 22:12:47 -0400 Subject: [PATCH 03/11] Add HTML5 Gamepad API support to the WASM backend Polls navigator.getGamepads() once per UpdateInput and feeds the results through the same internal.JoystickState machinery the desktop backend uses. Standard-layout pads are remapped so button / axis order matches the GLFW backend (including promoting LT/RT from analog buttons to the trigger axes); non-standard pads pass through as raw indices so applications can still address them. --- backends/opengl/input_wasm.go | 13 +- backends/opengl/joystick_wasm.go | 228 +++++++++++++++++++++++++++++-- backends/opengl/window_wasm.go | 3 +- 3 files changed, 227 insertions(+), 17 deletions(-) diff --git a/backends/opengl/input_wasm.go b/backends/opengl/input_wasm.go index 68a2919..ec0b0a3 100644 --- a/backends/opengl/input_wasm.go +++ b/backends/opengl/input_wasm.go @@ -39,10 +39,17 @@ func (w *Window) SetScrollCallback(cb func(win *Window, scroll pixel.Vec)) { w.scrollCallback = cb } -// UpdateInput commits the pending input events for the next frame. -func (w *Window) UpdateInput() { w.input.Update() } +// UpdateInput commits the pending input events for the next frame and polls +// the connected gamepads via the HTML5 Gamepad API. +func (w *Window) UpdateInput() { + w.input.Update() + w.updateJoystickInput() +} // UpdateInputWait commits pending events. The timeout argument is accepted for // API parity with the desktop backend but ignored under WASM — the browser // event loop delivers events asynchronously. -func (w *Window) UpdateInputWait(timeout time.Duration) { w.input.Update() } +func (w *Window) UpdateInputWait(timeout time.Duration) { + w.input.Update() + w.updateJoystickInput() +} diff --git a/backends/opengl/joystick_wasm.go b/backends/opengl/joystick_wasm.go index 8272032..290cc55 100644 --- a/backends/opengl/joystick_wasm.go +++ b/backends/opengl/joystick_wasm.go @@ -2,16 +2,218 @@ package opengl -import "github.com/gopxl/pixel/v2" - -// Gamepads are out of scope for the WASM backend; all queries report -// "not connected". - -func (w *Window) JoystickPresent(pixel.Joystick) bool { return false } -func (w *Window) JoystickName(pixel.Joystick) string { return "" } -func (w *Window) JoystickButtonCount(pixel.Joystick) int { return 0 } -func (w *Window) JoystickAxisCount(pixel.Joystick) int { return 0 } -func (w *Window) JoystickPressed(pixel.Joystick, pixel.GamepadButton) bool { return false } -func (w *Window) JoystickJustPressed(pixel.Joystick, pixel.GamepadButton) bool { return false } -func (w *Window) JoystickJustReleased(pixel.Joystick, pixel.GamepadButton) bool { return false } -func (w *Window) JoystickAxis(pixel.Joystick, pixel.GamepadAxis) float64 { return 0 } +import ( + "syscall/js" + + "github.com/gopxl/pixel/v2" +) + +// Browser "standard" gamepad button indices (see +// https://www.w3.org/TR/gamepad/#remapping). +const ( + webBtnA = 0 + webBtnB = 1 + webBtnX = 2 + webBtnY = 3 + webBtnLeftBumper = 4 + webBtnRightBumper = 5 + webBtnLeftTrigger = 6 + webBtnRightTrigger = 7 + webBtnBack = 8 + webBtnStart = 9 + webBtnLeftThumb = 10 + webBtnRightThumb = 11 + webBtnDpadUp = 12 + webBtnDpadDown = 13 + webBtnDpadLeft = 14 + webBtnDpadRight = 15 + webBtnGuide = 16 +) + +// webButtonToPixel maps the browser's standard-layout button indices onto +// pixel's GamepadButton values. Triggers (6, 7) are intentionally omitted +// here and surface as AxisLeftTrigger / AxisRightTrigger so the API matches +// the desktop GLFW backend. +var webButtonToPixel = map[int]pixel.GamepadButton{ + webBtnA: pixel.GamepadA, + webBtnB: pixel.GamepadB, + webBtnX: pixel.GamepadX, + webBtnY: pixel.GamepadY, + webBtnLeftBumper: pixel.GamepadLeftBumper, + webBtnRightBumper: pixel.GamepadRightBumper, + webBtnBack: pixel.GamepadBack, + webBtnStart: pixel.GamepadStart, + webBtnGuide: pixel.GamepadGuide, + webBtnLeftThumb: pixel.GamepadLeftThumb, + webBtnRightThumb: pixel.GamepadRightThumb, + webBtnDpadUp: pixel.GamepadDpadUp, + webBtnDpadRight: pixel.GamepadDpadRight, + webBtnDpadDown: pixel.GamepadDpadDown, + webBtnDpadLeft: pixel.GamepadDpadLeft, +} + +// JoystickPresent reports whether a gamepad is connected in the given slot. +// +// This API is experimental. +func (w *Window) JoystickPresent(j pixel.Joystick) bool { + return w.currJoy.Connected[j] +} + +// JoystickName returns the navigator-supplied id for the gamepad in the given +// slot, or an empty string if no gamepad is present. +// +// This API is experimental. +func (w *Window) JoystickName(j pixel.Joystick) string { + return w.currJoy.Name[j] +} + +// JoystickButtonCount returns the number of buttons exposed by the connected +// gamepad. Returns 0 for disconnected slots. +// +// This API is experimental. +func (w *Window) JoystickButtonCount(j pixel.Joystick) int { + return len(w.currJoy.Buttons[j]) +} + +// JoystickAxisCount returns the number of axes exposed by the connected +// gamepad. Returns 0 for disconnected slots. +// +// This API is experimental. +func (w *Window) JoystickAxisCount(j pixel.Joystick) int { + return len(w.currJoy.Axis[j]) +} + +// JoystickPressed reports whether the given button is currently held. +// +// This API is experimental. +func (w *Window) JoystickPressed(j pixel.Joystick, button pixel.GamepadButton) bool { + return w.currJoy.GetButton(j, button) +} + +// JoystickJustPressed reports whether the given button transitioned to +// pressed since the last UpdateInput. +// +// This API is experimental. +func (w *Window) JoystickJustPressed(j pixel.Joystick, button pixel.GamepadButton) bool { + return w.currJoy.GetButton(j, button) && !w.prevJoy.GetButton(j, button) +} + +// JoystickJustReleased reports whether the given button transitioned to +// released since the last UpdateInput. +// +// This API is experimental. +func (w *Window) JoystickJustReleased(j pixel.Joystick, button pixel.GamepadButton) bool { + return !w.currJoy.GetButton(j, button) && w.prevJoy.GetButton(j, button) +} + +// JoystickAxis returns the current value of the given axis, in [-1, 1] for +// sticks and [0, 1] for triggers. +// +// This API is experimental. +func (w *Window) JoystickAxis(j pixel.Joystick, axis pixel.GamepadAxis) float64 { + return w.currJoy.GetAxis(j, axis) +} + +// updateJoystickInput polls navigator.getGamepads() and rotates the joystick +// state snapshots. Called once per frame from UpdateInput. +// +// The Gamepad API is poll-based: the browser updates snapshots asynchronously +// and the page observes state by re-reading the list each frame. +func (w *Window) updateJoystickInput() { + pads := browserGamepads() + for slot := 0; slot < pixel.NumJoysticks; slot++ { + joy := pixel.Joystick(slot) + if !pads.Truthy() || slot >= pads.Length() { + w.clearJoySlot(joy) + continue + } + pad := pads.Index(slot) + if !pad.Truthy() || !pad.Get("connected").Bool() { + w.clearJoySlot(joy) + continue + } + w.tempJoy.Connected[joy] = true + w.tempJoy.Name[joy] = pad.Get("id").String() + w.tempJoy.Buttons[joy], w.tempJoy.Axis[joy] = readPadState(pad) + } + w.prevJoy = w.currJoy + w.currJoy = w.tempJoy +} + +func (w *Window) clearJoySlot(j pixel.Joystick) { + w.tempJoy.Connected[j] = false + w.tempJoy.Buttons[j] = nil + w.tempJoy.Axis[j] = nil + w.tempJoy.Name[j] = "" +} + +// browserGamepads calls navigator.getGamepads() and returns the resulting +// array. Returns js.Undefined() when the host lacks the Gamepad API (older +// browsers, non-secure contexts, headless environments). +func browserGamepads() js.Value { + nav := js.Global().Get("navigator") + if !nav.Truthy() { + return js.Undefined() + } + fn := nav.Get("getGamepads") + if !fn.Truthy() { + return js.Undefined() + } + return nav.Call("getGamepads") +} + +// readPadState converts a browser Gamepad object into pixel's +// (buttons, axes) representation. Standard-layout pads are remapped so the +// button and axis order matches the desktop GLFW backend; non-standard pads +// pass through untouched so applications can still address raw indices. +func readPadState(pad js.Value) ([]pixel.Action, []float32) { + mapping := pad.Get("mapping").String() + btnsJS := pad.Get("buttons") + axesJS := pad.Get("axes") + btnN := btnsJS.Length() + axN := axesJS.Length() + + if mapping == "standard" { + buttons := make([]pixel.Action, pixel.NumGamepadButtons) + for i := 0; i < btnN; i++ { + target, ok := webButtonToPixel[i] + if !ok { + continue + } + if btnsJS.Index(i).Get("pressed").Bool() { + buttons[target] = pixel.Press + } else { + buttons[target] = pixel.Release + } + } + // Standard axes are [LX, LY, RX, RY]. Pixel also exposes the two + // triggers as axes, but on the web they are reported as analog + // buttons (indices 6 and 7) — promote them so downstream code sees + // the same axis layout as on desktop. + axes := make([]float32, pixel.NumAxes) + for i := 0; i < axN && i < 4; i++ { + axes[i] = float32(axesJS.Index(i).Float()) + } + if btnN > webBtnLeftTrigger { + axes[pixel.AxisLeftTrigger] = float32(btnsJS.Index(webBtnLeftTrigger).Get("value").Float()) + } + if btnN > webBtnRightTrigger { + axes[pixel.AxisRightTrigger] = float32(btnsJS.Index(webBtnRightTrigger).Get("value").Float()) + } + return buttons, axes + } + + buttons := make([]pixel.Action, btnN) + for i := 0; i < btnN; i++ { + if btnsJS.Index(i).Get("pressed").Bool() { + buttons[i] = pixel.Press + } else { + buttons[i] = pixel.Release + } + } + axes := make([]float32, axN) + for i := 0; i < axN; i++ { + axes[i] = float32(axesJS.Index(i).Float()) + } + return buttons, axes +} diff --git a/backends/opengl/window_wasm.go b/backends/opengl/window_wasm.go index 6f5fd56..90e1b14 100644 --- a/backends/opengl/window_wasm.go +++ b/backends/opengl/window_wasm.go @@ -50,7 +50,8 @@ type Window struct { vsync bool cursorVisible bool - input internal.InputHandler + input internal.InputHandler + prevJoy, currJoy, tempJoy internal.JoystickState buttonCallback func(win *Window, button pixel.Button, action pixel.Action) charCallback func(win *Window, r rune) From 0dc804eb37c055319fb635848307764762e329c5 Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Tue, 21 Apr 2026 10:51:18 -0400 Subject: [PATCH 04/11] Fix Canvas.Clear race on WASM: use blocking Call not CallNonBlock On WASM, mainthread.CallNonBlock spawns a goroutine. Every WebGL call via syscall/js is a goroutine scheduling point, so the deferred clear can preempt between draw calls and wipe an in-flight render. Using the blocking Call ensures the clear runs synchronously in order with other GL operations. --- backends/opengl/canvas.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/backends/opengl/canvas.go b/backends/opengl/canvas.go index 34d452c..17578e0 100644 --- a/backends/opengl/canvas.go +++ b/backends/opengl/canvas.go @@ -184,6 +184,11 @@ func setBlendFunc(cmp pixel.ComposeMethod) { glhf.BlendFunc(glhf.One, glhf.One) case pixel.ComposeCopy: glhf.BlendFunc(glhf.One, glhf.Zero) + case pixel.ComposeMultiply: + glhf.BlendFunc(glhf.DstColor, glhf.Zero) + case pixel.ComposeScreen: + glhf.BlendEquation(glhf.FuncAdd) + glhf.BlendFuncSeparate(glhf.One, glhf.OneMinusSrcColor, glhf.Zero, glhf.One) default: panic(errors.New("Canvas: invalid compose method")) } @@ -203,7 +208,11 @@ func (c *Canvas) Clear(color color.Color) { A: float64(c.col[3]), }) - mainthread.CallNonBlock(func() { + // Use Call (blocking) not CallNonBlock: on WASM, CallNonBlock spawns a + // goroutine, and every WebGL call is a goroutine scheduling point — the + // deferred clear would race with in-flight draw calls and wipe content + // mid-render. + mainthread.Call(func() { c.setGlhfBounds() c.gf.Frame().Begin() glhf.Clear( @@ -317,8 +326,18 @@ func (ct *canvasTriangles) draw(tex *glhf.Texture, bounds pixel.Rect) { } for loc, u := range ct.shader.uniforms { +<<<<<<< HEAD + ct.shader.s.SetUniformAttr(loc, u.Value()) + } +======= + if u.isSampler && u.tex != nil { + glhf.ActiveTexture(u.unit) + u.tex.Begin() + } ct.shader.s.SetUniformAttr(loc, u.Value()) } + glhf.ActiveTexture(0) +>>>>>>> 6987ad7 (Fix Canvas.Clear race on WASM: use blocking Call not CallNonBlock) if tex == nil { ct.vs.Begin() From a1280bf736b1f12f515f6de7d2bddd7bab51ecde Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Tue, 21 Apr 2026 11:17:11 -0400 Subject: [PATCH 05/11] Fix all CallNonBlock draw calls on WASM: use blocking Call On WASM, mainthread.CallNonBlock spawns a goroutine for each GL call. Because every syscall/js invocation is a goroutine scheduling point, a non-blocking draw or vertex upload can be preempted by window.Update (SwapBuffers) before it runs, producing black frames. Changed both canvasTriangles.draw and CopyVertices to use the blocking Call. CopyVertices previously used CallNonBlock only for small batches (<256 floats) as a perf optimization. The optimization is dropped in favor of correctness across all platforms. --- backends/opengl/canvas.go | 6 +++++- backends/opengl/gltriangles.go | 24 ++++++++---------------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/backends/opengl/canvas.go b/backends/opengl/canvas.go index 17578e0..302c554 100644 --- a/backends/opengl/canvas.go +++ b/backends/opengl/canvas.go @@ -297,7 +297,11 @@ func (ct *canvasTriangles) draw(tex *glhf.Texture, bounds pixel.Rect) { mat := ct.dst.mat col := ct.dst.col - mainthread.CallNonBlock(func() { + // Use Call (blocking) not CallNonBlock: same race as Canvas.Clear — on + // WASM every WebGL call is a goroutine scheduling point, so a non-blocking + // draw can be preempted by window.Update (SwapBuffers) before it executes, + // producing a black frame. + mainthread.Call(func() { ct.dst.setGlhfBounds() setBlendFunc(cmp) diff --git a/backends/opengl/gltriangles.go b/backends/opengl/gltriangles.go index 7220d4a..2c80c02 100644 --- a/backends/opengl/gltriangles.go +++ b/backends/opengl/gltriangles.go @@ -201,22 +201,14 @@ func (gt *GLTriangles) Update(t pixel.Triangles) { // CopyVertices copies the GLTriangle data down to the vertex data. func (gt *GLTriangles) CopyVertices() { - // this code is supposed to copy the vertex data and CallNonBlock the update if - // the data is small enough, otherwise it'll block and not copy the data - if len(gt.data) < 256 { // arbitrary heurestic constant - data := append([]float32{}, gt.data...) - mainthread.CallNonBlock(func() { - gt.vs.Begin() - gt.vs.SetVertexData(data) - gt.vs.End() - }) - } else { - mainthread.Call(func() { - gt.vs.Begin() - gt.vs.SetVertexData(gt.data) - gt.vs.End() - }) - } + // Always use blocking Call. On WASM, CallNonBlock spawns a goroutine that + // can be preempted by a subsequent draw or SwapBuffers before the vertex + // upload completes, causing draws with stale or empty buffers. + mainthread.Call(func() { + gt.vs.Begin() + gt.vs.SetVertexData(gt.data) + gt.vs.End() + }) } // Copy returns an independent copy of this GLTriangles. From 68611c8deb50cb60665c0fa118baf64249de4a55 Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Tue, 21 Apr 2026 12:06:39 -0400 Subject: [PATCH 06/11] Add touch event support for mobile browsers Maps touchstart/touchmove/touchend/touchcancel to MouseButton1 + mouse move events so single-finger touch works identically to a left-click on all platforms. preventDefault on all touch events (with passive:false) stops browser pan, zoom, and long-press callout behaviours. --- backends/opengl/input_dom_wasm.go | 78 +++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/backends/opengl/input_dom_wasm.go b/backends/opengl/input_dom_wasm.go index ba1c32a..718b6fa 100644 --- a/backends/opengl/input_dom_wasm.go +++ b/backends/opengl/input_dom_wasm.go @@ -289,6 +289,63 @@ func (w *Window) initInput() { return nil }) + // Touch events — map single-touch to MouseButton1 so the game receives + // touch input on mobile browsers. preventDefault on all touch events stops + // the browser's default pan/zoom/callout behaviours; passive:false is + // required to allow that call. + nonPassive := map[string]any{"passive": false} + + touchStart := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + ev := args[0] + ev.Call("preventDefault") + touches := ev.Get("changedTouches") + if touches.Length() == 0 { + return nil + } + pos := w.touchPosFromTouch(touches.Index(0)) + w.input.MouseMoveEvent(pos) + w.input.ButtonEvent(pixel.MouseButton1, true) + w.fireButtonCallback(pixel.MouseButton1, pixel.Press) + return nil + }) + + touchMove := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + ev := args[0] + ev.Call("preventDefault") + touches := ev.Get("touches") + if touches.Length() == 0 { + return nil + } + pos := w.touchPosFromTouch(touches.Index(0)) + w.input.MouseMoveEvent(pos) + if w.mouseMovedCallback != nil { + w.mouseMovedCallback(w, pos) + } + return nil + }) + + touchEnd := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 0 { + return nil + } + ev := args[0] + ev.Call("preventDefault") + touches := ev.Get("changedTouches") + if touches.Length() > 0 { + pos := w.touchPosFromTouch(touches.Index(0)) + w.input.MouseMoveEvent(pos) + } + w.input.ButtonEvent(pixel.MouseButton1, false) + w.fireButtonCallback(pixel.MouseButton1, pixel.Release) + return nil + }) + w.jsCanvas.Call("addEventListener", "keydown", keyDown) w.jsCanvas.Call("addEventListener", "keyup", keyUp) w.jsCanvas.Call("addEventListener", "blur", blur) @@ -299,6 +356,10 @@ func (w *Window) initInput() { w.jsCanvas.Call("addEventListener", "mouseleave", mouseLeave) w.jsCanvas.Call("addEventListener", "wheel", wheel, map[string]any{"passive": false}) w.jsCanvas.Call("addEventListener", "contextmenu", contextMenu) + w.jsCanvas.Call("addEventListener", "touchstart", touchStart, nonPassive) + w.jsCanvas.Call("addEventListener", "touchmove", touchMove, nonPassive) + w.jsCanvas.Call("addEventListener", "touchend", touchEnd, nonPassive) + w.jsCanvas.Call("addEventListener", "touchcancel", touchEnd, nonPassive) } // mousePosFromEvent converts a MouseEvent's clientX/Y (in CSS pixels, @@ -320,6 +381,23 @@ func (w *Window) mousePosFromEvent(ev js.Value) pixel.Vec { return pixel.V(x, y) } +// touchPosFromTouch converts a single Touch object's clientX/Y into +// window-local pixel coordinates, using the same mapping as mousePosFromEvent. +func (w *Window) touchPosFromTouch(touch js.Value) pixel.Vec { + rect := w.jsCanvas.Call("getBoundingClientRect") + cssX := touch.Get("clientX").Float() - rect.Get("left").Float() + cssY := touch.Get("clientY").Float() - rect.Get("top").Float() + cssW := rect.Get("width").Float() + cssH := rect.Get("height").Float() + if cssW <= 0 || cssH <= 0 { + return pixel.ZV + } + bounds := w.bounds + x := bounds.Min.X + (cssX/cssW)*bounds.W() + y := bounds.Min.Y + (1-cssY/cssH)*bounds.H() + return pixel.V(x, y) +} + // fireButtonCallback invokes the user-registered button callback if any. func (w *Window) fireButtonCallback(btn pixel.Button, action pixel.Action) { if w.buttonCallback != nil { From 63921f97479c097a789028373e888e2c390b1a64 Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Tue, 21 Apr 2026 12:07:23 -0400 Subject: [PATCH 07/11] Fix touch ButtonEvent calls: use pixel.Press/Release not bool --- backends/opengl/input_dom_wasm.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backends/opengl/input_dom_wasm.go b/backends/opengl/input_dom_wasm.go index 718b6fa..b748aa5 100644 --- a/backends/opengl/input_dom_wasm.go +++ b/backends/opengl/input_dom_wasm.go @@ -307,7 +307,7 @@ func (w *Window) initInput() { } pos := w.touchPosFromTouch(touches.Index(0)) w.input.MouseMoveEvent(pos) - w.input.ButtonEvent(pixel.MouseButton1, true) + w.input.ButtonEvent(pixel.MouseButton1, pixel.Press) w.fireButtonCallback(pixel.MouseButton1, pixel.Press) return nil }) @@ -341,7 +341,7 @@ func (w *Window) initInput() { pos := w.touchPosFromTouch(touches.Index(0)) w.input.MouseMoveEvent(pos) } - w.input.ButtonEvent(pixel.MouseButton1, false) + w.input.ButtonEvent(pixel.MouseButton1, pixel.Release) w.fireButtonCallback(pixel.MouseButton1, pixel.Release) return nil }) From 562ccdf96d42ed75f13cc8ad433a1fc717ba6e99 Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Tue, 21 Apr 2026 12:37:05 -0400 Subject: [PATCH 08/11] webglcontextlost: call pixelOnContextLost callback instead of auto-reloading Auto-reload was silent and jarring on mobile (iOS drops WebGL contexts when switching tabs). Now the page handles recovery UX via the callback; falls back to reload if the callback is not defined. --- backends/opengl/window_wasm.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/backends/opengl/window_wasm.go b/backends/opengl/window_wasm.go index 90e1b14..d8db087 100644 --- a/backends/opengl/window_wasm.go +++ b/backends/opengl/window_wasm.go @@ -115,15 +115,22 @@ func NewWindow(cfg WindowConfig) (*Window, error) { return win, nil } -// installContextLostHandler logs and full-page-reloads on WebGL context loss. -// True in-place recovery is out of scope; reload is the simplest safe action. +// installContextLostHandler notifies the page when the WebGL context is lost. +// True in-place recovery is out of scope. If window.pixelOnContextLost is +// defined the page handles the response (show UI, prompt reload, etc.); +// otherwise we fall back to an immediate location.reload(). func (w *Window) installContextLostHandler() { lost := js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) > 0 { args[0].Call("preventDefault") } - js.Global().Get("console").Call("warn", "webglcontextlost — reloading") - js.Global().Get("location").Call("reload") + js.Global().Get("console").Call("warn", "webglcontextlost") + cb := js.Global().Get("pixelOnContextLost") + if cb.Truthy() { + cb.Invoke() + } else { + js.Global().Get("location").Call("reload") + } return nil }) w.jsCanvas.Call("addEventListener", "webglcontextlost", lost) From 5393705072e62ba53e92f04c4eda8b803f514c7c Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Tue, 21 Apr 2026 12:41:05 -0400 Subject: [PATCH 09/11] Cap DPR at 2 and add context-loss diagnostics MaxDevicePixelRatio (default 2) prevents DPR=3 devices (iPhone Pro) from allocating a ~12 MB framebuffer that exhausts iOS GPU memory limits. DPR=2 is indistinguishable for pixel-art content. pixelOnContextLost now receives a diagnostics object: renderer, dpr, backing store dimensions, estimated framebuffer size, elapsed time, and loss count so the page can surface actionable info to the user. --- backends/opengl/window_wasm.go | 40 ++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/backends/opengl/window_wasm.go b/backends/opengl/window_wasm.go index d8db087..3987d42 100644 --- a/backends/opengl/window_wasm.go +++ b/backends/opengl/window_wasm.go @@ -36,6 +36,13 @@ type WindowConfig struct { BoundsLimits pixel.Rect } +// MaxDevicePixelRatio caps the backing-store scale factor used by syncCanvasSize. +// iOS/WebKit drops the WebGL context when GPU memory is exhausted; DPR=3 on +// modern iPhones produces a ~12 MB framebuffer for a fullscreen canvas, which +// combined with game textures easily hits the iOS limit. DPR=2 gives sharp +// rendering at 2x scale for essentially no visual downgrade in a pixel-art game. +var MaxDevicePixelRatio = 2.0 + // Window wraps an HTML5 canvas plus a WebGL2 rendering context. The internal // Canvas handles all drawing; Update blits it to the default framebuffer and // yields to requestAnimationFrame. @@ -50,6 +57,9 @@ type Window struct { vsync bool cursorVisible bool + gpuRenderer string // captured at init for diagnostics + ctxLostCount int + input internal.InputHandler prevJoy, currJoy, tempJoy internal.JoystickState @@ -100,6 +110,18 @@ func NewWindow(cfg WindowConfig) (*Window, error) { cursorVisible: true, } + // Capture GPU renderer string for diagnostics. WEBGL_debug_renderer_info + // may be blocked by the browser (privacy), so fall back gracefully. + if ext := gl.Call("getExtension", "WEBGL_debug_renderer_info"); ext.Truthy() { + unmaskedRenderer := ext.Get("UNMASKED_RENDERER_WEBGL") + if unmaskedRenderer.Truthy() { + win.gpuRenderer = gl.Call("getParameter", unmaskedRenderer).String() + } + } + if win.gpuRenderer == "" { + win.gpuRenderer = gl.Call("getParameter", js.Global().Get("WebGL2RenderingContext").Get("RENDERER")).String() + } + win.canvas = NewCanvas(cfg.Bounds) currWin = win @@ -124,10 +146,22 @@ func (w *Window) installContextLostHandler() { if len(args) > 0 { args[0].Call("preventDefault") } - js.Global().Get("console").Call("warn", "webglcontextlost") + w.ctxLostCount++ + dpr := js.Global().Get("devicePixelRatio").Float() + fbW := w.jsCanvas.Get("width").Int() + fbH := w.jsCanvas.Get("height").Int() + elapsed := js.Global().Get("performance").Call("now").Float() + diag := js.Global().Get("Object").New() + diag.Set("renderer", w.gpuRenderer) + diag.Set("dpr", dpr) + diag.Set("backingW", fbW) + diag.Set("backingH", fbH) + diag.Set("elapsedMs", elapsed) + diag.Set("count", w.ctxLostCount) + js.Global().Get("console").Call("warn", "webglcontextlost", diag) cb := js.Global().Get("pixelOnContextLost") if cb.Truthy() { - cb.Invoke() + cb.Invoke(diag) } else { js.Global().Get("location").Call("reload") } @@ -162,6 +196,8 @@ func (w *Window) syncCanvasSize() { dpr := js.Global().Get("devicePixelRatio").Float() if dpr < 1 { dpr = 1 + } else if MaxDevicePixelRatio > 0 && dpr > MaxDevicePixelRatio { + dpr = MaxDevicePixelRatio } targetW := int(float64(cssW) * dpr) targetH := int(float64(cssH) * dpr) From 7f495d3cb101515f22fb04f13433ec92e1dc609a Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Tue, 21 Apr 2026 12:53:32 -0400 Subject: [PATCH 10/11] Add multi-touch support: ActiveTouches() and correct Press/Release counting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track all active touches by Touch.identifier in Window.activeTouches. ActiveTouches() returns a snapshot of all simultaneous touch positions so callers (e.g. virtual gamepads) can hit-test each finger independently. Fix MouseButton1 edge detection under multi-touch: previously any finger lifting fired Release even if other fingers were still down. Now Press fires only when the count goes 0→1, Release only when it goes 1→0. Desktop Window gets a nil-returning stub so the method is available on both platforms without conditional compilation at call sites. --- backends/opengl/input_dom_wasm.go | 74 ++++++++++++++++++++++--------- backends/opengl/window.go | 6 +++ backends/opengl/window_wasm.go | 18 +++++++- 3 files changed, 77 insertions(+), 21 deletions(-) diff --git a/backends/opengl/input_dom_wasm.go b/backends/opengl/input_dom_wasm.go index b748aa5..2494dc5 100644 --- a/backends/opengl/input_dom_wasm.go +++ b/backends/opengl/input_dom_wasm.go @@ -199,6 +199,14 @@ func (w *Window) initInput() { for _, btn := range domMouseButton { w.input.ButtonEvent(btn, pixel.Release) } + // Clear touch state so no touches appear stuck after losing focus. + for id := range w.activeTouches { + delete(w.activeTouches, id) + } + if w.touchCount > 0 { + w.touchCount = 0 + w.input.ButtonEvent(pixel.MouseButton1, pixel.Release) + } return nil }) @@ -301,14 +309,21 @@ func (w *Window) initInput() { } ev := args[0] ev.Call("preventDefault") - touches := ev.Get("changedTouches") - if touches.Length() == 0 { - return nil + changed := ev.Get("changedTouches") + for i := 0; i < changed.Length(); i++ { + t := changed.Index(i) + id := t.Get("identifier").Int() + pos := w.touchPosFromTouch(t) + w.activeTouches[id] = pos + w.input.MouseMoveEvent(pos) } - pos := w.touchPosFromTouch(touches.Index(0)) - w.input.MouseMoveEvent(pos) - w.input.ButtonEvent(pixel.MouseButton1, pixel.Press) - w.fireButtonCallback(pixel.MouseButton1, pixel.Press) + // Fire Press only on the first finger down so MouseButton1 behaves as + // a simple "any touch active" signal rather than toggling on each finger. + if w.touchCount == 0 { + w.input.ButtonEvent(pixel.MouseButton1, pixel.Press) + w.fireButtonCallback(pixel.MouseButton1, pixel.Press) + } + w.touchCount += changed.Length() return nil }) @@ -318,14 +333,25 @@ func (w *Window) initInput() { } ev := args[0] ev.Call("preventDefault") - touches := ev.Get("touches") - if touches.Length() == 0 { - return nil + // Update all moved touches in the active map. + changed := ev.Get("changedTouches") + for i := 0; i < changed.Length(); i++ { + t := changed.Index(i) + id := t.Get("identifier").Int() + pos := w.touchPosFromTouch(t) + if _, active := w.activeTouches[id]; active { + w.activeTouches[id] = pos + } } - pos := w.touchPosFromTouch(touches.Index(0)) - w.input.MouseMoveEvent(pos) - if w.mouseMovedCallback != nil { - w.mouseMovedCallback(w, pos) + // Forward the first currently-active touch as the mouse position so + // single-touch code (settings panel, etc.) continues to work. + all := ev.Get("touches") + if all.Length() > 0 { + pos := w.touchPosFromTouch(all.Index(0)) + w.input.MouseMoveEvent(pos) + if w.mouseMovedCallback != nil { + w.mouseMovedCallback(w, pos) + } } return nil }) @@ -336,13 +362,21 @@ func (w *Window) initInput() { } ev := args[0] ev.Call("preventDefault") - touches := ev.Get("changedTouches") - if touches.Length() > 0 { - pos := w.touchPosFromTouch(touches.Index(0)) - w.input.MouseMoveEvent(pos) + changed := ev.Get("changedTouches") + for i := 0; i < changed.Length(); i++ { + t := changed.Index(i) + id := t.Get("identifier").Int() + if pos, ok := w.activeTouches[id]; ok { + w.input.MouseMoveEvent(pos) + delete(w.activeTouches, id) + } + } + w.touchCount -= changed.Length() + if w.touchCount <= 0 { + w.touchCount = 0 + w.input.ButtonEvent(pixel.MouseButton1, pixel.Release) + w.fireButtonCallback(pixel.MouseButton1, pixel.Release) } - w.input.ButtonEvent(pixel.MouseButton1, pixel.Release) - w.fireButtonCallback(pixel.MouseButton1, pixel.Release) return nil }) diff --git a/backends/opengl/window.go b/backends/opengl/window.go index 15b652b..6bf0935 100644 --- a/backends/opengl/window.go +++ b/backends/opengl/window.go @@ -577,3 +577,9 @@ func (w *Window) SetClipboard(str string) { w.window.SetClipboardString(str) }) } + + +// ActiveTouches returns the positions of all currently held touch points in +// window-local pixel coordinates. Desktop builds have no touch hardware, so +// this always returns nil; use win.Pressed(pixel.MouseButton1) for mouse input. +func (w *Window) ActiveTouches() []pixel.Vec { return nil } diff --git a/backends/opengl/window_wasm.go b/backends/opengl/window_wasm.go index 3987d42..e2cb726 100644 --- a/backends/opengl/window_wasm.go +++ b/backends/opengl/window_wasm.go @@ -57,9 +57,12 @@ type Window struct { vsync bool cursorVisible bool - gpuRenderer string // captured at init for diagnostics + gpuRenderer string // captured at init for diagnostics ctxLostCount int + activeTouches map[int]pixel.Vec // keyed by Touch.identifier; updated by touch event handlers + touchCount int // number of currently-held touches; guards MouseButton1 edge detection + input internal.InputHandler prevJoy, currJoy, tempJoy internal.JoystickState @@ -108,6 +111,7 @@ func NewWindow(cfg WindowConfig) (*Window, error) { gl: gl, vsync: cfg.VSync, cursorVisible: true, + activeTouches: make(map[int]pixel.Vec), } // Capture GPU renderer string for diagnostics. WEBGL_debug_renderer_info @@ -271,6 +275,18 @@ func (w *Window) Focused() bool { return js.Global().Get("document").Call("hasFocus").Bool() } +// ActiveTouches returns a snapshot of all currently-held touch positions in +// window-local pixel coordinates, one entry per active Touch.identifier. +// Use this instead of MousePosition for multi-touch hit-testing (e.g. virtual +// gamepads) because MousePosition only reflects the most-recent touch move. +func (w *Window) ActiveTouches() []pixel.Vec { + out := make([]pixel.Vec, 0, len(w.activeTouches)) + for _, v := range w.activeTouches { + out = append(out, v) + } + return out +} + func (w *Window) SetVSync(vsync bool) { w.vsync = vsync } func (w *Window) VSync() bool { return w.vsync } From b16cc62fb0b46d72fe9fe0e1c582a749415445bb Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Tue, 21 Apr 2026 12:57:23 -0400 Subject: [PATCH 11/11] Expose GPU renderer string to JS as window._gpuRenderer at init --- backends/opengl/window_wasm.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backends/opengl/window_wasm.go b/backends/opengl/window_wasm.go index e2cb726..0973cc9 100644 --- a/backends/opengl/window_wasm.go +++ b/backends/opengl/window_wasm.go @@ -129,6 +129,12 @@ func NewWindow(cfg WindowConfig) (*Window, error) { win.canvas = NewCanvas(cfg.Bounds) currWin = win + // Expose GPU renderer string to JS so the page can include it in crash + // diagnostics (heartbeat, context-lost overlay, etc.). + if win.gpuRenderer != "" { + js.Global().Set("_gpuRenderer", win.gpuRenderer) + } + // Ensure the canvas can receive keyboard focus inside iframes. if jsCanvas.Get("tabIndex").Int() < 0 { jsCanvas.Set("tabIndex", 0)