graphics.gd

repository·release·Indexed 21 days ago

https://github.com/quaadgras/graphics.gd

A cross-platform 2D/3D graphics runtime for Go that provides a cohesive experience for using Go with the Godot Engine and GDExtension. It includes the `gd` command-line tool for building, testing, and managing assets, as well as a language-hosted DSL for writing type-safe shaders in Go. Designed for native mobile apps, games, and multimedia applications, it supports 64-bit architectures across Windows, Linux, MacOS, Android, iOS, and Web.

Tokens
39.4K
Snippets
104
Records
155
Agent score
74%

What's inside graphics.gd

  1. Overview of the Web (js/wasm) test harness

    release

    The Web (js/wasm) test harness is a tool designed to run the internal test suite inside a real browser against a Godot web export. It operates headlessly and reports pass/fail results to CI.

    Workflow

    1. Build & Serve: Running gd test (with GOOS=js) builds the go test binary to library.wasm, downloads the Godot web export template, and executes the Godot Web export preset. This produces a standard Godot web build (index.html, index.wasm, index.pck) alongside the Go library.wasm and wasm_exec.js. The result is served on :$PORT with the required cross-origin-isolation headers.
    2. Browser Execution: run_browser_test.mjs launches headless Chromium via the DevTools protocol. Go's stdout is routed to console.log via wasm_exec.js, allowing standard go test output to appear in the browser console.

    Exit Codes

    • 0: PASS/ok line detected.
    • 1: FAIL/--- FAIL/exit code: N/panic/uncaught exception detected.
    • 2: Timeout occurred.
  2. Write Shaders in Go using a language-hosted DSL

    release

    Shaders in graphics.gd are written using a language-hosted Domain Specific Language (DSL). Instead of writing GLSL directly, you call Go functions that record an Abstract Syntax Tree (AST). This AST is then compiled into Godot's GLSL variant.

    This approach provides:

    • Type safety: Leverages Go's type system.
    • Composition: Easily combine shader logic.
    • IDE Integration: Use standard Go tooling.
    • Compile-time evaluation: Go branches and side effects are evaluated during the 'compile time' (when the AST is recorded), making it ideal for branchless shaders.
  3. How to use Godot classes and methods in Go

    release

    graphics.gd maps Godot engine classes to Go packages under the classdb directory.

    Importing Classes

    To use a class like Node, import it via its package path: import "graphics.gd/classdb/Node"

    Inheritance and Casting

    There is no formal inheritance in the Go API. To access a superclass or cast an object, use the AsClassName() method or specific casting methods provided by the class:

    • AsObject()
    • AsNode2D()
    • AsControl()

    Method Naming

    Methods have been renamed from Godot's snake_case to Go's PascalCase convention. For example, get_tree() in GDScript becomes Get() in Go (note: some utility functions may have moved packages, e.g., Node.get_tree() is now SceneTree.Get()).

    Handling Optional Arguments

    By default, optional arguments are omitted. To specify them, convert an Instance into either the MoreArgs or Advanced types:

    node.MoreArgs().AddChild(...)
  4. How cross-thread dispatch works

    release

    To prevent deadlocks and race conditions in Godot's non-thread-safe API, calls made from non-main goroutines are routed through a Multi-Producer Single-Consumer (MPSC) ring buffer.

    The Workflow:

    1. Routing: Using threadcheck.Main(), calls from non-main goroutines are detected.
    2. Dispatch:
      • Void calls: Pushed to the MPSC ring as fire-and-forget tasks.
      • Calls needing results: Pushed to the ring, and the calling goroutine blocks until the entry status is marked DONE.
    3. Execution: The main thread drains this ring every frame (via startup/garbage_collector.go), executing the calls in a single batch.
    4. Result Delivery: Results are written directly into the ring entry. The blocked goroutine reads the return value from the entry itself, avoiding per-call allocations.

    Key Safety Features:

    • Panic Handling: Panics inside a Run thunk are captured by the drain and re-raised on the original calling goroutine. Panics in deferred thunks propagate on the main thread after the cursor advances.
    • Memory Safety: Result slots are zeroed before dispatch to prevent stale bytes from triggering incorrect unrefs of Godot objects (like StringName or Array).
    • Shutdown: Calling ring.Threads.Close() at engine exit ensures remaining buffered calls are drained while the engine is still alive, and prevents goroutines from parking indefinitely.
    // Conceptual flow of a cross-thread call
    // 1. Goroutine calls a method
    // 2. threadcheck.Main() detects it's not the main thread
    // 3. Call is pushed to MPSC ring
    // 4. Main thread drains ring and executes call
    // 5. Result is placed in ring; goroutine wakes and reads it
  5. When does a buffer flush occur?

    release

    Understanding flush triggers is critical for performance. A flush is a cgo crossing that synchronizes the Go state with the Godot engine.

    No Flush (High Performance):

    • Returning a reference type (e.g., Object, String, Ref). The system returns a ring-tagged pointer.
    • Passing a ring-tagged pointer as an argument to another buffered call. The system records a reference in the buffer.

    Partial Flush:

    • When the write head of the ring buffer wraps around to a slot containing an unmaterialized result. The system off-loads that entry to clear the slot.

    Full Flush (Pipeline Stall):

    • Frame boundary: The system flushes all pending commands.
    • Observing concrete values: When Go code attempts to inspect or perform arithmetic on a concrete value type (e.g., int, float, Vector2) returned from the engine, a flush is forced to materialize the value.
  6. How the FFI Command Buffer works

    release

    To avoid the high cost of frequent cgo crossings, graphics.gd uses a ring buffer (command buffer) architecture. Instead of executing every engine call immediately, calls are recorded into a buffer and executed in a single batch (flush).

    This design enables:

    • Automatic cross-thread dispatch: Calls from non-main goroutines are recorded in an MPSC (Multi-Producer Single-Consumer) ring buffer and drained by the main thread.
    • Batched execution: Multiple calls are processed in one cgo crossing.
    • Deferred execution: Reference types (like Object, String, or Ref) are returned as "ring-tagged pointers" that don't trigger an immediate flush, allowing you to chain engine calls without stalling the pipeline.
  7. How Sticky-P inbound dispatch works

    release

    Sticky-P is an optimization mechanism designed to eliminate the high overhead of runtime.cgocallback transitions during inbound virtual calls (such as _process or _physics_process) from the engine to Go.

    In a standard flow, every call incurs syscall-state transitions (reentersyscall and exitsyscall) which can account for a significant portion of the call overhead. Sticky-P optimizes this by acquiring a Processor (P) once per frame's virtual batch and keeping it live across the engine-C gaps between calls.

    The Three Execution Paths:

    1. Cold path: Used for the first call of a batch or after a Stop-The-World (STW) event. It establishes the Go-to-P connection via the direct runtime.cgocallback path but skips the release on return, making the P "sticky."
    2. Fast path: Used when the P is still held and no STW is pending. It switches to the sticky goroutine stack, runs the Dispatch function, and switches back without triggering exitsyscall or entersyscall.
    3. Teardown: Triggered by EndFrame (via an on_every_frame handler), which releases the P to ensure inter-frame engine work can run with STW unblocked.
  8. Sticky-P constraints and limitations

    release

    While Sticky-P significantly reduces overhead, it introduces specific constraints that developers must be aware of:

    • Blocking Calls: A _process function that blocks on a cross-goroutine wait is unsupported on the sticky path. Such a call could potentially sit behind the very STW event it is delaying.
    • Deadlock Prevention: To prevent deadlocks, Sticky-P follows two rules:
      1. The P is always released at the frame boundary (ensuring a maximum delay of $\le 1$ frame).
      2. The system checks gcWaiting at each call entry; if set, it forces the standard (cold) path (ensuring a maximum delay of $\le 1$ call-gap).
    • STW Interaction: Sticky-P keeps the P out of _Psyscall across gaps so that STW can neither steal it nor wait-to-safepoint while the thread is in engine C.
  9. Identify engine-owned threads and safe direct calls

    release

    Not all non-main threads can be queued via the MPSC ring. Threads that the engine calls into Go (such as the resource-loading thread or WorkerThreadPool threads) must make engine calls directly to avoid deadlocks.

    Detection and Behavior:

    • Registration: threadcheck.Mark() is called upon entry to engine→Go callbacks to register these threads.
    • Reporting: threadcheck.Engine() identifies these threads.
    • Bypass: Calls from engine-owned threads bypass the ring and execute directly.

    Re-entrancy Safety: To prevent user goroutines from being incorrectly misclassified as engine-owned during object construction, the gdextension.Host bindings use threadcheck.EnterCall and threadcheck.LeaveCall to bracket crossings. Mark() ignores callbacks that occur between these two calls.

  10. Understanding Ring-Tagged Pointers

    release

    In graphics.gd, Go code often interacts with "virtual" pointers called ring-tagged pointers. These are used to represent Godot objects or reference types that haven't been materialized into real memory addresses yet.

    A pointer's value indicates its state via the least significant bit (bit 0):

    • bit 0 = 0: A real, aligned Godot pointer.
    • bit 0 = 1: A ring index (the actual index is value >> 1).

    When you pass a ring-tagged pointer as an argument to another buffered call, the system records a reference to the previous entry's result rather than forcing a flush. The pointer is only "materialized" (the tag bit is cleared and the real address is written to the Go-side pointer) when the ring buffer wraps around or a flush is explicitly triggered.

  11. Fast Main-Thread Detection via `internal/threadcheck`

    release

    The project uses a high-performance mechanism to detect if the current goroutine is the main thread. This is used to decide whether to use the SPSC (Single-Producer Single-Consumer) ring buffer or the MPSC (Multi-Producer Single-Consumer) ring buffer.

    Detection is implemented by comparing the current goroutine's g register against the one captured at initialization. This avoids syscalls and cgo overhead, costing only ~1-3 CPU cycles.

    Platform-specific implementation details:

    • amd64: Uses MOVQ R14, ret+0(FP).
    • arm64: Uses MOVD g, ret+0(FP).
    • wasm: Always returns true (single-threaded).
    • Other: Falls back to gdextension.Host.Threads.Main() via cgo.
  12. Manage lifetimes of reference types off the main thread

    release

    When working with Godot reference types (e.g., String, StringName, NodePath, Array, Dictionary, Variant, Callable, Signal) in goroutines, you must use anchored state rather than frame-temporaries.

    The Problem: On the main thread, wrappers are 'frame-temporaries' that expire after two cycles. Goroutines operate on their own timeline and might outlive these cycles, leading to use-after-free errors.

    The Solution (Anchoring):

    • All wrap sites use gd.Wrap* helpers.
    • For goroutines, these helpers construct pinned values using runtime.AddCleanup via an anchor.
    • The Go garbage collector governs the lifetime of these anchors.
    • Cleanup is handled via ring.Threads.Defer, ensuring the engine value is freed via noescape.Free only after all buffered uses in the MPSC ring have been executed (FIFO order).

    Important Invariant: Values created on the main thread remain frame-temporaries even if a goroutine later uses them. Cross-thread sharing of main-created wrappers follows standard main-thread lifetime rules.