ultralightui

package module
v0.1.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 26, 2026 License: MIT Imports: 13 Imported by: 0

README

ultralightui

Render HTML/CSS/JS interfaces as textures in Ebitengine using Ultralight 1.4.

  • Full HTML5/CSS3/JS engine rendered to an off-screen bitmap
  • Transparent backgrounds (HTML layers on top of your game)
  • Bidirectional Go <-> JavaScript communication via native JavaScriptCore bindings
  • Multiple independent views with per-view input routing
  • Mouse, scroll, and keyboard forwarding from Ebiten to Ultralight
  • Embedded assets via VFS: bundle HTML/CSS/JS/images inside the binary with go:embed
  • No CGo required (uses purego + a small C bridge DLL)

Architecture

Go (Ebitengine) <--purego--> ul_bridge shared lib (GCC) <--dlopen/LoadLibrary--> Ultralight 1.4 libs

The C bridge shared library (ul_bridge.c) runs all Ultralight API calls on a dedicated worker thread (Ultralight requires single-thread affinity). Go communicates with it through simple exported functions via purego. On Windows it uses Win32 APIs (LoadLibrary, CreateThread, CreateEvent); on Linux/macOS it uses POSIX equivalents (dlopen, pthread, pthread_cond).

Setup

Prerequisites
Tool Version What for
Go 1.21+ Build your application
Ebitengine v2.8+ Game engine (pulled automatically by go mod)
GCC Any Compile the bridge shared library from source (w64devkit on Windows, system GCC on Linux/macOS)
Ultralight SDK 1.4 HTML rendering engine (download separately)
Step 1: Download the Ultralight 1.4 SDK

Go to ultralig.ht/download (requires a free account) and download the SDK for your platform (Windows x64, Linux x64, or macOS). Extract the archive. You'll get a folder like this:

ultralight-sdk/
  bin/
    Ultralight.dll / libUltralight.so / libUltralight.dylib
    UltralightCore.dll / libUltralightCore.so / libUltralightCore.dylib
    WebCore.dll / libWebCore.so / libWebCore.dylib
    AppCore.dll / libAppCore.so / libAppCore.dylib
  resources/
    cacert.pem
    icudt67l.dat        <-- ICU Unicode data (required for all text/JS)
  include/              <-- C headers (not needed at runtime)
  lib/                  <-- .lib/.a files (not needed, we load libs at runtime)
  ...
Step 2: Copy SDK files to your project

Copy the SDK libraries and data files into the working directory of your application (typically the folder where your main.go is, or wherever you run the executable from):

# From the SDK archive (use the files matching your platform):
cp ultralight-sdk/bin/*Ultralight*      your-project/
cp ultralight-sdk/bin/*UltralightCore*  your-project/
cp ultralight-sdk/bin/*WebCore*         your-project/
cp ultralight-sdk/bin/*AppCore*         your-project/
cp ultralight-sdk/resources/icudt67l.dat  your-project/

SDK library names per platform:

Library Windows Linux macOS
Ultralight Ultralight.dll libUltralight.so libUltralight.dylib
UltralightCore UltralightCore.dll libUltralightCore.so libUltralightCore.dylib
WebCore WebCore.dll libWebCore.so libWebCore.dylib
AppCore AppCore.dll libAppCore.so libAppCore.dylib

What each library does:

Library Size Purpose
Ultralight ~1 MB Core Ultralight API (renderer, views, surfaces)
UltralightCore ~4 MB Low-level rendering backend
WebCore ~35 MB HTML/CSS/JS engine (WebKit fork + JavaScriptCore)
AppCore ~0.5 MB Platform helpers (font loader, file system, logger)
icudt67l.dat ~25 MB ICU Unicode data tables. Required for all text rendering, JavaScript string operations, CSS text layout, regex, and locale-aware formatting. Without this file, Ultralight cannot process any text.
Step 3: Compile the bridge DLL

The bridge is a thin C layer between Go and Ultralight. The source is included in this repository at bridge/ul_bridge.c. Compile it with GCC:

# Windows:
gcc -shared -o ul_bridge.dll bridge/ul_bridge.c -O2 -lkernel32

# Linux:
gcc -shared -fPIC -o libul_bridge.so bridge/ul_bridge.c -O2 -lpthread -ldl

# macOS:
gcc -shared -fPIC -o libul_bridge.dylib bridge/ul_bridge.c -O2 -lpthread -ldl

Windows tip: If you don't have GCC in your PATH, download w64devkit, extract it, and run from its shell. It's a single portable zip, no installer needed.

Place the resulting shared library in the same directory as the Ultralight SDK libraries.

Step 4: Add the Go module
go get github.com/YindSoft/ultralight-ebitengine-port@latest
Final directory layout

Your project directory should look like this:

your-project/
  main.go
  go.mod
  go.sum
  Ultralight.dll / libUltralight.so / libUltralight.dylib       \
  UltralightCore.dll / libUltralightCore.so / ...                 |  from Ultralight SDK
  WebCore.dll / libWebCore.so / ...                               |
  AppCore.dll / libAppCore.so / ...                               |
  icudt67l.dat                                                   /
  ul_bridge.dll / libul_bridge.so / libul_bridge.dylib   <-- compiled from bridge/ul_bridge.c
  ui/
    index.html            <-- your HTML interface

You can also place the libraries in a separate directory and pass it via Options.BaseDir.

Quick Start

package main

import (
    "fmt"
    ultralightui "github.com/YindSoft/ultralight-ebitengine-port"
    "github.com/hajimehoshi/ebiten/v2"
)

type Game struct {
    ui *ultralightui.UltralightUI
}

func (g *Game) Update() error { return g.ui.Update() }

func (g *Game) Draw(screen *ebiten.Image) {
    screen.DrawImage(g.ui.GetTexture(), nil)
}

func (g *Game) Layout(w, h int) (int, int) { return 800, 600 }

func main() {
    ui, err := ultralightui.NewFromFile(800, 600, "ui/index.html", nil)
    if err != nil {
        panic(err)
    }
    defer ui.Close()

    ui.OnMessage = func(msg string) {
        fmt.Println("from JS:", msg)
    }

    ebiten.SetWindowSize(800, 600)
    ebiten.RunGame(&Game{ui: ui})
}

API

Creating views
// From a local HTML file (path relative to working directory):
ui, err := ultralightui.NewFromFile(width, height, "ui/index.html", nil)

// From raw HTML bytes:
ui, err := ultralightui.NewFromHTML(width, height, []byte("<h1>Hello</h1>"), nil)

// From a URL:
ui, err := ultralightui.NewFromURL(width, height, "https://example.com", nil)

All sync constructors (NewFromFile, NewFromHTML, NewFromURL, NewFromFS) return a fully loaded view in a single call (~3-7 ms). For non-blocking creation, use the async variant:

// Async: returns immediately, view loads in the background (~5 ticks / ~83ms)
ui, err := ultralightui.NewFromFSAsync(800, 600, "ui/index.html", uiFiles, nil)
// ui.IsReady() returns false until loading completes
// ui.Update() is safe to call immediately (renders transparent until ready)
Options
opts := &ultralightui.Options{
    BaseDir: "/path/to/libs",  // Where to find the bridge and SDK libraries (default: working dir)
    Debug:   true,             // Create bridge.log and ultralight.log for troubleshooting
}
ui, err := ultralightui.NewFromFile(800, 600, "ui/index.html", opts)
JS -> Go (messages)

JavaScript sends messages to Go using go.send():

// Send a string
go.send("attack");

// Send JSON (receives as a JSON string on the Go side)
go.send(JSON.stringify({ action: "move", x: 10, y: 20 }));

Go receives messages via the OnMessage callback:

ui.OnMessage = func(msg string) {
    // msg is "attack" or '{"action":"move","x":10,"y":20}'
    data, err := ultralightui.ParseMessage(msg) // auto-parses JSON
    fmt.Println(data)
}

This uses native JavaScriptCore bindings under the hood (no console.log hacks).

Go -> JS (eval and send)

Run arbitrary JavaScript:

ui.Eval("document.getElementById('score').textContent = '100'")
ui.Eval("updateHP(75, 100)") // call a JS function you defined

Send structured data (serialized as JSON):

ui.Send(map[string]any{"hp": 80, "maxHp": 100, "items": []string{"sword", "shield"}})

JavaScript receives it via go.receive:

go.receive = function(data) {
    console.log(data.hp, data.maxHp);   // 80 100
    console.log(data.items);            // ["sword", "shield"]
};
Input

Mouse and scroll events are forwarded when the cursor is inside the view's bounds. Keyboard events go to whichever view has focus.

// Restrict this view to a screen region (for multi-view layouts):
ui.SetBounds(x, y, width, height)

// Give keyboard focus to this view:
ui.SetFocus()

Clicking inside a view automatically gives it focus.

Multiple views

You can create multiple independent views, each with its own HTML page:

mainUI, _ := ultralightui.NewFromFile(600, 400, "ui/main.html", nil)
sidebar, _ := ultralightui.NewFromFile(200, 400, "ui/sidebar.html", nil)

mainUI.SetBounds(0, 0, 600, 400)
sidebar.SetBounds(600, 0, 200, 400)
Transparency

HTML views have transparent backgrounds by default. This lets you layer HTML on top of your game rendering. Use CSS background: rgba(...) for semi-transparent panels.

Embedded assets (VFS)

You can bundle all your HTML/CSS/JS/images inside the Go binary using go:embed and the built-in virtual file system (VFS). No files need to exist on disk at runtime (except the Ultralight SDK libraries and icudt67l.dat).

package main

import (
    "embed"
    ultralightui "github.com/YindSoft/ultralight-ebitengine-port"
    "github.com/hajimehoshi/ebiten/v2"
)

//go:embed ui
var uiFiles embed.FS

type Game struct{ ui *ultralightui.UltralightUI }
func (g *Game) Update() error              { return g.ui.Update() }
func (g *Game) Draw(screen *ebiten.Image)  { screen.DrawImage(g.ui.GetTexture(), nil) }
func (g *Game) Layout(w, h int) (int, int) { return 800, 600 }

func main() {
    ui, err := ultralightui.NewFromFS(800, 600, "ui/index.html", uiFiles, nil)
    if err != nil { panic(err) }
    defer ui.Close()

    ebiten.SetWindowSize(800, 600)
    ebiten.RunGame(&Game{ui: ui})
}
How it works
  1. go:embed ui compiles the entire ui/ folder into the binary
  2. NewFromFS walks the embedded FS and registers every file in a C-level VFS via RegisterFile()
  3. The VFS is checked first on every Ultralight file request; disk is used as fallback
  4. The main page is loaded via file:///ui/index.html which resolves from the VFS
  5. All relative references (<link href="style.css">, <script src="app.js">, etc.) resolve from the VFS too
VFS API

You can also register individual files manually (useful for dynamic content):

// Register files before creating views that reference them
ultralightui.RegisterFile("ui/config.json", configBytes)

// Query the number of registered files
count := ultralightui.VFSFileCount()

// Clear all registered files
ultralightui.ClearFiles()

Examples

Multi-view example

screenshot

See the example/ directory for a complete working demo with two views, animated Ebiten shapes behind HTML, bidirectional communication, and keyboard input.

cd example
go run .
Embedded assets example

See the example_embed/ directory for a demo that loads all HTML/CSS/JS from go:embed with no files on disk. It demonstrates:

  • Separate .html, .css, and .js files all served from the VFS
  • Go -> JS communication (counter updates every second)
  • JS -> Go communication (button clicks via go.send())
cd example_embed
go run .

Both examples expect the SDK libraries to be in the parent directory (the repo root).

Performance

View creation is optimized for minimal latency. The bridge uses a fast sync path that combines view creation and content loading into a single worker thread roundtrip with no sleep loops:

Scenario Time
Simple HTML ~3 ms
Complex HTML (commerce UI) ~7 ms
URL (file:/// with VFS) ~2.5 ms
6 views (simple) ~13 ms
6 views (complex) ~35 ms
First view (cold start, includes renderer init) ~32 ms

The pixel pipeline uses an async goroutine for BGRA-to-RGBA conversion, keeping the main game loop free from blocking work. Dirty tracking ensures pixel copies only happen when the surface has actually changed.

How it works (internals)

  1. The bridge shared library loads the Ultralight SDK at runtime via LoadLibrary/GetProcAddress (Windows) or dlopen/dlsym (Linux/macOS)
  2. A dedicated worker thread handles all Ultralight API calls (renderer, views, JS eval)
  3. Go sends commands to the worker thread via a simple command queue with platform-native synchronization (Win32 events on Windows, pthread mutex+cond on POSIX)
  4. View creation and content loading are combined into a single worker command (CMD_CREATE_WITH_HTML / CMD_CREATE_WITH_URL) for minimum latency
  5. Pixel data is read from Ultralight's surface bitmap (BGRA), converted to RGBA in an async goroutine, and written to an Ebiten image
  6. JS -> Go communication uses JavaScriptCore's native C API (JSObjectMakeFunctionWithCallback) to register window.go.send() as a native function that pushes messages to a queue
  7. Go -> JS communication calls window.go.receive(data) via ulViewEvaluateScript

Troubleshooting

Problem Solution
failed to load ul_bridge Make sure the bridge shared library (ul_bridge.dll / libul_bridge.so / libul_bridge.dylib) is in your working directory or in Options.BaseDir. Recompile it if needed.
FAIL: Ultralight / FAIL: WebCore in bridge.log One of the SDK libraries is missing. Copy all 4 libraries from the SDK bin/ folder.
All pixels are zero / blank screen Make sure icudt67l.dat is present. Enable Debug: true and check ultralight.log.
Buttons don't respond to clicks Verify SetBounds() matches where you draw the texture. Input is only forwarded inside bounds.
Keyboard doesn't work Call SetFocus() on the view, or click inside it first.

Platform support

Platform Bridge library SDK libraries Status
Windows x64 ul_bridge.dll *.dll Tested
Linux x64 libul_bridge.so lib*.so Supported (requires Ultralight Linux SDK)
macOS x64/arm64 libul_bridge.dylib lib*.dylib Supported (requires Ultralight macOS SDK)

The C bridge uses #ifdef _WIN32 to select between Win32 APIs and POSIX equivalents (dlopen/dlsym, pthread_create, pthread_mutex/pthread_cond). The Go side uses build tags (bridge_windows.go, bridge_unix.go) to select the library loading mechanism.

License

MIT - Copyright (c) 2026 Javier Podavini (YindSoft)

Ultralight is a separate product with its own license. The SDK DLLs are not included in this repository; you must download them from the Ultralight website.

Documentation

Overview

Package ultralightui renders HTML views as Ebiten textures using Ultralight 1.4.

It provides a simple API for embedding HTML/CSS/JS interfaces in Ebitengine games and applications, with bidirectional Go <-> JavaScript communication.

Basic usage:

import "github.com/YindSoft/ultralight-ebitengine-port"

// Create a UI from a local HTML file, a URL, or raw HTML bytes:
ui, err := ultralightui.NewFromFile(800, 600, "ui/index.html", nil)
// or: ultralightui.NewFromURL(800, 600, "https://example.com", nil)
// or: ultralightui.NewFromHTML(800, 600, htmlBytes, nil)
if err != nil { ... }
defer ui.Close()

// Receive messages from the page (JS calls go.send("action") or go.send({key: "val"}))
ui.OnMessage = func(msg string) {
    data, _ := ultralightui.ParseMessage(msg) // parses JSON if applicable
    ...
}

// In Ebiten Update():
ui.Update()

// In Ebiten Draw():
screen.DrawImage(ui.GetTexture(), opts)

// Optional: restrict input to a screen region and manage keyboard focus
ui.SetBounds(0, 0, 800, 600)
ui.SetFocus()

// Send structured data to the page (serialized as JSON):
ui.Send(map[string]any{"hp": 80, "maxHp": 100})
// HTML receives it via: go.receive = function(data) { ... }

// Run arbitrary JavaScript:
ui.Eval("updateScore(100)")

Embedded assets (VFS):

Use NewFromFS to load HTML/CSS/JS/images from an embed.FS or any fs.FS. Files are registered in a virtual file system so Ultralight serves them from memory, without exposing assets on disk:

//go:embed ui
var uiFiles embed.FS
ui, err := ultralightui.NewFromFS(800, 600, "ui/index.html", uiFiles, nil)

JS -> Go communication uses native JavaScriptCore bindings (no console.log hacks). Go -> JS communication calls window.go.receive(data) with parsed JSON.

Multiple UltralightUI instances are supported. Each has its own Ultralight view. Mouse and scroll input are forwarded when the cursor is inside the view's bounds. Keyboard input goes to whichever view has focus (via SetFocus or clicking).

Requirements: the bridge shared library (ul_bridge.dll on Windows, libul_bridge.so on Linux, libul_bridge.dylib on macOS) and the Ultralight 1.4 SDK libraries must be present next to the executable or in the directory specified by [Options.BaseDir].

Index

Constants

This section is empty.

Variables

View Source
var GlobalCursorOffsetX int

GlobalCursorOffsetX/Y se restan de las coordenadas del cursor antes de verificar bounds y calcular coordenadas locales. Usar cuando el área de contenido no empieza en (0,0) (ej: un borde + topbar desplazan el contenido).

View Source
var GlobalCursorOffsetY int

Functions

func ClearFiles added in v0.1.0

func ClearFiles()

ClearFiles frees all files registered in the VFS.

func ClearFocus added in v0.1.1

func ClearFocus()

ClearFocus removes keyboard focus from all views. After this call, no UI receives key events (keys go back to the game).

func ParseMessage

func ParseMessage(msg string) (interface{}, error)

ParseMessage parses msg as JSON if it looks like JSON (starts with '{' or '['). Returns the parsed value, or the raw string if it's not JSON.

func RegisterFile added in v0.1.0

func RegisterFile(filePath string, data []byte) error

RegisterFile registers a file in Ultralight's VFS. filePath is the virtual path (e.g., "ui/style.css"). data is the content. Registered files take priority over disk files. Must be called BEFORE creating views that reference them.

func Tick added in v0.1.1

func Tick()

Tick calls the Ultralight renderer once (Update + RefreshDisplay + Render for all views). When using multiple views, call Tick() once per frame BEFORE calling UpdateNoTick() on each view. This avoids redundant renderer cycles that happen when each view calls Update().

func VFSFileCount added in v0.1.0

func VFSFileCount() int

VFSFileCount returns the number of files registered in the VFS.

Types

type Options

type Options struct {
	BaseDir string // Directory containing the bridge shared library and Ultralight SDK libraries. Defaults to working directory.
	Debug   bool   // Enable debug logging (creates bridge.log and ultralight.log). Default false.
}

Options for creating the UI. All fields are optional.

type UltralightUI

type UltralightUI struct {

	// Bounds in screen coordinates for input routing. Set via SetBounds so that
	// only the view under the cursor receives mouse/scroll input.
	BoundsX, BoundsY, BoundsW, BoundsH int

	// OnMessage is called when the page sends a message via go.send(msg).
	// msg is a string or JSON string. Use ParseMessage to get structured data.
	OnMessage func(msg string)
	// contains filtered or unexported fields
}

UltralightUI represents an HTML view rendered as an Ebiten texture. Multiple instances can exist; each has its own view in the Ultralight bridge.

func New

func New(width, height int, htmlPath string, opts *Options) (*UltralightUI, error)

New is a convenience alias for NewFromFile.

func NewFromFS added in v0.1.0

func NewFromFS(width, height int, mainFile string, fsys fs.FS, opts *Options) (*UltralightUI, error)

NewFromFS creates a new UI loading all files from the given fs.FS into Ultralight's VFS, then loads mainFile as the main page.

mainFile is relative to the FS root (e.g., "ui/index.html"). All files in the FS are registered so that <link>, <script>, <img> can reference them with relative paths.

Example with embed.FS:

//go:embed ui
var uiFiles embed.FS
ui, err := ultralightui.NewFromFS(800, 600, "ui/index.html", uiFiles, nil)

func NewFromFSAsync added in v0.1.1

func NewFromFSAsync(width, height int, mainFile string, fsys fs.FS, opts *Options) (*UltralightUI, error)

NewFromFSAsync is like NewFromFS but creates the view asynchronously. The view is returned immediately but is not yet ready to use. Call IsReady() to check when loading is complete (~5 ticks / ~83ms). Update() can be called immediately; it handles the async state gracefully. Pixel output will be empty/transparent until the view is ready.

func NewFromFile

func NewFromFile(width, height int, filePath string, opts *Options) (*UltralightUI, error)

NewFromFile creates a new UI loading HTML from a local file.

func NewFromHTML

func NewFromHTML(width, height int, html []byte, opts *Options) (*UltralightUI, error)

NewFromHTML creates a new UI with the given HTML bytes (no file or URL).

func NewFromURL

func NewFromURL(width, height int, url string, opts *Options) (*UltralightUI, error)

NewFromURL creates a new UI loading content from a URL.

func (*UltralightUI) Close

func (ui *UltralightUI) Close()

Close releases resources. Call when done (e.g. defer ui.Close()). After Close, the UI must not be used.

func (*UltralightUI) Eval

func (ui *UltralightUI) Eval(script string)

Eval runs JavaScript in the page. Fire-and-forget (no return value).

func (*UltralightUI) GetTexture

func (ui *UltralightUI) GetTexture() *ebiten.Image

GetTexture returns the Ebiten image with the current HTML content rendered.

func (*UltralightUI) IsReady added in v0.1.1

func (ui *UltralightUI) IsReady() bool

IsReady returns true if the view has finished async loading and is usable. For synchronously created views this always returns true. For async views (NewFromFSAsync), it returns false until priming+loading is done.

func (*UltralightUI) MarkDirty added in v0.1.1

func (ui *UltralightUI) MarkDirty()

MarkDirty es un no-op mantenido por compatibilidad. Los pixels se copian automaticamente cada frame cuando Ultralight tiene cambios pendientes.

func (*UltralightUI) Send

func (ui *UltralightUI) Send(data interface{}) error

Send sends structured data to the page. It serializes to JSON and invokes window.go.receive(data). Define go.receive in your HTML to handle it.

func (*UltralightUI) SetBounds

func (ui *UltralightUI) SetBounds(x, y, w, h int)

SetBounds sets the screen rectangle for this UI. Mouse and scroll are only forwarded when the cursor is inside these bounds. Keyboard goes to the focused UI. Use (0,0,0,0) to disable input.

func (*UltralightUI) SetFocus

func (ui *UltralightUI) SetFocus()

SetFocus gives this UI keyboard focus. Only the focused UI receives key events, regardless of cursor position. Mouse and scroll still require the cursor inside bounds. Clicking inside a UI also gives it focus.

func (*UltralightUI) Update

func (ui *UltralightUI) Update() error

Update should be called every frame from the game's Update. It ticks Ultralight, copies pixels to the texture, polls messages, and forwards input. Note: each call to Update() triggers a full renderer cycle for ALL views. For multiple views, prefer calling Tick() once then UpdateNoTick() on each view.

func (*UltralightUI) UpdateNoTick added in v0.1.1

func (ui *UltralightUI) UpdateNoTick() error

UpdateNoTick does everything Update() does EXCEPT calling ulTick(). Use with Tick(): call Tick() once per frame, then UpdateNoTick() on each view.

Directories

Path Synopsis
Example of NewFromFS: loads HTML/CSS/JS from embed.FS (no files on disk).
Example of NewFromFS: loads HTML/CSS/JS from embed.FS (no files on disk).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL