Skip to content

glplat: minimal GL platform layer (experiment) — GLFW/OpenGL backend, font primitives, screenshot - #392

Draft
nnunley wants to merge 4 commits into
nooga:mainfrom
nnunley:glplat
Draft

glplat: minimal GL platform layer (experiment) — GLFW/OpenGL backend, font primitives, screenshot#392
nnunley wants to merge 4 commits into
nooga:mainfrom
nnunley:glplat

Conversation

@nnunley

@nnunley nnunley commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Experimental platform layer backing xsofy's GL frontend spike (see the companion xsofy gl-frontend branch). Draft — for visibility and review of the seams, not necessarily for merging as-is.

What's here

  • pkg/glplat: pure-Go public API delegating through an interface/registry to internal/native (cgo: GLFW 3.3 + OpenGL 4.1 core). Contract: 9-float vertices (x y z u v r g b a), SubmitTriangles draws immediately with the current column-major MVP, texture 0 = untextured white.
  • Font primitives (FontLoad ttf/otf/ttc, FontHasGlyph, FontRasterizeCell, SaveGlyphAtlasPNG) — minimal rasterization surface so atlas-baking policy lives in lg code (xsofy tools/bake_atlas.lg), per 'as minimal as possible in Go'. Replaces the earlier cmd/lgatlas CLI (deleted).
  • Screenshot(path): glReadPixels of the back buffer → PNG, for headless frame verification.
  • Hand-written rt bindings in pkg/rt/interop_glplat.go (lginterop style; the source importer can't typecheck cgo packages).

Notes


Part of #259 (Epic: Host integration & interop) — realizes the graphics host-capability seam scoped in #255.

@nooga nooga left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a solid spike and the seams (registry/interface, pure-Go API vs internal/native) look right. Before this lands non-draft, the cgo/GLFW dependency needs to be behind a build tag (e.g. //go:build glplat on internal/native and a stub/no-op registration otherwise) so go build ./... and the wasm target don't require GLFW/OpenGL/cgo on every machine and CI runner. Right now pkg/rt unconditionally pulls in the native backend, which would break headless CI and the existing GOOS=js wasm build.

@nnunley

nnunley commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (8c31a809) to refresh CI — the prior failures ran against a stale base. Locally on this branch: pkg/glplat builds (GL/GLFW cgo), the non-glplat build is clean, and make check-generated is green (so the generated-artifacts / gogen-diff jobs should pass now). Still a draft/experiment — just refreshing CI.

@nnunley

nnunley commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Build-tag gate implemented in 2382162: //go:build glplat on internal/native and on the blank-import shim (pkg/glplat/init.go). Without the tag no backend registers and the pure-Go API returns its existing no backend registered errors; pkg/rt/interop_glplat.go only touches that pure API, so nothing in the default build needs cgo.

Verified all three configurations locally:

  • go build ./... (no tag) — succeeds, no GLFW/cgo required
  • GOOS=js GOARCH=wasm go build ./pkg/... — succeeds
  • go build -tags glplat ./pkg/glplat/... — succeeds with the native backend compiled in

Staying draft for now — it's still a spike — but the unconditional-cgo blocker is gone.

@nnunley nnunley closed this Jul 17, 2026
@nnunley nnunley reopened this Jul 17, 2026
nnunley added 4 commits July 17, 2026 08:51
…wport from framebuffer size each frame (Retina)
FontLoad (ttf/otf/ttc), FontHasGlyph, FontRasterizeCell (fit-to-cell alpha
grid), SaveGlyphAtlasPNG — minimal Go surface; layout/policy moved to
xsofy's tools/bake_atlas.lg. cmd/lgatlas deleted (replaced).
glReadPixels of the back buffer (call after rendering, before EndFrame
swaps), rows flipped, alpha forced opaque. Registry interface + native
backend + public API + rt binding.
Without the tag no backend registers: the pure-Go API returns
'no backend registered' errors, and plain go build ./..., headless CI,
and the GOOS=js wasm target no longer require GLFW/OpenGL/cgo.
Build with -tags glplat to get the native GLFW/OpenGL backend.
@nnunley

nnunley commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (a1a3866e) and CI is now fully green on the build-tag gate commit — lint, build, gogen-diff, and generated-artifacts all pass without GLFW/OpenGL/cgo, and the GOOS=js wasm build is clean.

@mparrett mparrett left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked at the tip (489d5c62) with the build-tag gate in. The seams (registry, pure-Go API, -tags glplat) look right, and the unconditional-cgo blocker is gone. A few concrete issues to fix before this leaves draft:

P1: LoadTextureRGBA buffer length

LoadTextureRGBA passes w, h, and gl.Ptr(pixels) to gl.TexImage2D with no len(pixels) >= w*h*4 check (pkg/glplat/internal/native/native.go). A short slice (easy from the lg seq→byte unbox) is a C-side over-read. Reject up front in the public API and/or the native backend.

P2: texture Y orientation

LoadTextureFile / LoadTextureRGBA upload the first row first. OpenGL treats that as the texture bottom, while the spike UVs put v=0 at the top of the quad. Atlases from SaveGlyphAtlasPNG / FontRasterizeCell are top-down, so glyphs render upside-down unless something else compensates. Flip on upload (or invert V in the contract) and say which convention the API owns.

P2: WindowSize frozen at Init

BeginFrame refreshes the viewport from the framebuffer each frame (good for Retina), but WindowSize() returns the dimensions cached in Init. After a resize, ortho/UI math that trusts WindowSize drifts from the viewport. Read GetWindowSize() (and/or a size callback).

P2: Terminate leaves a live window pointer

window.Destroy() runs but b.window is not nil'd, and the texture map still holds deleted GL IDs. Later ShouldClose / BeginFrame / SubmitTriangles is use-after-destroy. Nil the window, clear maps/IDs, and decide whether re-Init is supported.

P2: font registry locking vs sfnt.Buffer

FontHasGlyph / FontRasterizeCell unlock, then call GlyphIndex on the shared entry.buf. Concurrent has-glyph/rasterize (or overlap with getFace mutating faces) races. Hold the mutex across buffer use, or give each call its own sfnt.Buffer.

P2: getFace swallows NewFace errors

Failed face creation stores nil; later DrawString panics. Easy to hit when width-fit scales int(newSize) to 0. Propagate the error and reject non-positive sizes.

Smaller / residual

  • No Go or lg tests for the contracts that matter (vertex stride, MVP column-major layout, screenshot-before-swap, font cell fit).
  • Spike examples don't say that lg needs -tags glplat.
  • Depth-test + alpha blending will bite overlapping translucent glyphs.
  • Unknown texture IDs fall through to GL texture 0 instead of the white fallback.
  • Hand-written pkg/rt/interop_glplat.go is labeled "Code generated by lginterop / DO NOT EDIT"; fine that it's hand-rolled (cgo), but the header will mislead the next editor.

Fonts usable without GLFW is the right cut of the surface. If this starts competing with a surface-style seam, spell out how it relates to #255's host-owned graphics model.

@mparrett

Copy link
Copy Markdown
Collaborator

We rebased this branch onto current main to build an lg we could measure the xsofy GL frontend against, and three things came out of it that seem useful here.

The rebase is nearly free

The branch was 42 commits behind origin/main when we took it on 2026-07-24. The entire rebase produced exactly one conflict, in .gitignore, resolved by keeping both sides. Everything else applied untouched. Whenever this comes off draft, that part is cheap.

Two notes from driving the input queue

Both only really show up from the client side, so they are easy to miss from inside the package.

One physical keypress arrives as two events. keyCallback appends key:<name> and charCallback appends char:<name> to the same queue, so a client dispatching on both acts twice per press. We settled on consuming char: and ignoring key: for printable keys, since char: is the one that carries shift state. That works fine once you know it — mostly flagging it in case it is worth a note in the docs.

keyCallback takes GLFW's mods and does not forward it. With no modifier information in the emitted string, a client cannot tell Shift+Left from Left, which is what put shift-to-run on arrow keys out of reach for us from the game side. Encoding modifiers into the event string, or emitting them alongside, would open that up whenever it matters.

A WebGL backend looks closer than expected

registry.Backend plus SetBackend already is the seam, and the build tag comment already names GOOS=js as a target that must not need cgo. Reading it as a porting surface:

  • The lg-facing API is 12 distinct functions, with a single runtime
    caller in our frontend. A conforming backend means the game code changes zero lines.
  • The shaders are #version 410 core; GLES 3.00 wants #version 300 es
    and precision qualifiers. layout(location = ...) on attributes is valid there, so the port is mechanical. It is textured coloured quads and two shaders.
  • Vertices already cross as a flat []float64, which is a direct
    Float32Array conversion, and there are only four to six SubmitTriangles calls per frame — few calls with large buffers, which is the shape that survives a wasm-to-JS boundary.
  • LoadTextureRGBA(pixels, w, h) is already on the interface, so the
    synchronous LoadTextureFile problem has an escape hatch: preload the atlas and hand over bytes.

The hard part is the loop. A frame loop that never returns starves the browser's event loop, and this one is built to block, with EndFrame doing the pacing. The way out that preserves the design is a Web Worker with an OffscreenCanvas, where blocking is allowed; the alternative is inverting the loop to be callback-driven, which pushes the change into game code and breaks the symmetry with the terminal frontend. A Worker backend would also have to supply the pacing itself, since there is no blocking swap to lean on.

We have not built this, so treat the shape as an assessment rather than a result.

One question before the seam sets

Should a browser backend live behind this package's own registry, or behind the surface capability from #255 as reshaped in #572? Those are two different extension points for the same kind of thing, and picking one is cheaper now than after both have implementations. That reads like a maintainer call rather than ours.

The two input findings stand on their own and are worth folding in whenever this branch next moves. The WebGL sketch can wait on that question — happy to work it up in detail, or to drop it if the answer makes it moot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants