summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 20:34:03 +0900
committerAdam Malczewski <[email protected]>2026-06-12 20:34:03 +0900
commita112b41d51ef8b114bbbbebb59eab1972750a23c (patch)
tree0d221f8913da50cb2609ef2961f9cb9e878b0615
parent8d7749516d70b8a27df4441c2b3e717de1a7a724 (diff)
downloadunbox-a112b41d51ef8b114bbbbebb59eab1972750a23c.tar.gz
unbox-a112b41d51ef8b114bbbbebb59eab1972750a23c.zip
Slice 3: THE SPIKE — RMLUi→wlr_scene bridge lands, GO
Plan A verified on hardware (HD 4400/crocus): RMLUi renders into a GLES 3.2 sibling-context FBO backed by a dmabuf wlr_buffer (wlr_allocator + EGLImage import), composited as a wlr_scene_buffer with per-frame damage. Plan B (glReadPixels→shm) implemented and verified as runtime fallback; auto-engages when any Plan-A precondition fails. Plan C not needed. - Hello-world RML doc: text, data-bound frame counter, pointer input proof (hover/:active) — verified upright on screen via screenshot after fixing the classic FBO Y-flip (buffer-level V-flip keeps display == document coords for input); position-aware orientation guard added. - Temporary spike surface: Options::ui_spike + frame-count/orientation probes, host-bin --ui-spike flag; replaced by the real ui substrate contract in slice 4+. - kernel suite 6 cases / 416 assertions green; ASan/UBSan clean in our code (Mesa leak noise + 2 benign UBSan downcast reports inside vendored RMLUi are known); idle RSS ≈83 MiB. - Deferred (notes/plan.md §7): glFinish→EGL fence + swapchain; dmabuf render-format negotiation (private API in wlroots 0.20).
-rw-r--r--notes/plan.md2
-rw-r--r--packages/host-bin/src/main.cpp6
-rw-r--r--packages/kernel/include/unbox/kernel/server.hpp20
-rw-r--r--packages/kernel/include/unbox/kernel/wlr.hpp13
-rw-r--r--packages/kernel/kernel.md79
-rw-r--r--packages/kernel/meson.build15
-rw-r--r--packages/kernel/src/input.cpp29
-rw-r--r--packages/kernel/src/rmlui_renderer_gl3.cpp2197
-rw-r--r--packages/kernel/src/rmlui_renderer_gl3.h227
-rw-r--r--packages/kernel/src/server.cpp40
-rw-r--r--packages/kernel/src/server_impl.hpp8
-rw-r--r--packages/kernel/src/ui_spike.cpp688
-rw-r--r--packages/kernel/src/ui_spike.hpp77
-rw-r--r--packages/kernel/tests/test_kernel.cpp72
-rw-r--r--tasks.md9
15 files changed, 3476 insertions, 6 deletions
diff --git a/notes/plan.md b/notes/plan.md
index da0da54..92fafb1 100644
--- a/notes/plan.md
+++ b/notes/plan.md
@@ -136,6 +136,8 @@ trusted.
| Workspace model (per-output? tags?) | undecided — design at slice 7 | tiling slice starts |
| clang-format style | defer config to slice 1 | first formatting dispute |
| Catch2 vs doctest revisit | doctest | doctest blocks something real |
+| dmabuf render-format negotiation (`wlr_renderer_get_render_formats` is private in wlroots 0.20) | hardcoded ARGB8888/LINEAR (verified on crocus) | wlroots bump slice or a GPU that rejects it |
+| ui-substrate frame sync: glFinish → EGL fence + 2-deep swapchain | per-frame glFinish (spike fidelity) | real ui substrate lands (slice 4+) |
## 8. References
diff --git a/packages/host-bin/src/main.cpp b/packages/host-bin/src/main.cpp
index eb3892c..cc17847 100644
--- a/packages/host-bin/src/main.cpp
+++ b/packages/host-bin/src/main.cpp
@@ -8,7 +8,7 @@
namespace {
void print_usage(const char* argv0) {
- std::printf("usage: %s [-s <startup command>]\n", argv0);
+ std::printf("usage: %s [-s <startup command>] [--ui-spike]\n", argv0);
}
} // namespace
@@ -19,6 +19,10 @@ auto main(int argc, char* argv[]) -> int {
const std::string_view arg = argv[i];
if (arg == "-s" && i + 1 < argc) {
options.startup_cmd = argv[++i];
+ } else if (arg == "--ui-spike") {
+ // Slice-3 spike surface (temporary): composite the hello-world
+ // RML document. Removed with the real ui substrate (slice 4+).
+ options.ui_spike = true;
} else {
print_usage(argv[0]);
return arg == "-h" ? 0 : 1;
diff --git a/packages/kernel/include/unbox/kernel/server.hpp b/packages/kernel/include/unbox/kernel/server.hpp
index 1095597..8389999 100644
--- a/packages/kernel/include/unbox/kernel/server.hpp
+++ b/packages/kernel/include/unbox/kernel/server.hpp
@@ -19,6 +19,14 @@ public:
// WAYLAND_DISPLAY pointing at this server) once the socket is
// live. Dev convenience mirroring tinywl's -s. Empty = nothing.
std::string startup_cmd{};
+
+ // Slice-3 spike surface (TEMPORARY — replaced by the real ui
+ // substrate contract in slice 4+). When true, the kernel composites
+ // a hello-world RML document as a wlr_scene_buffer node, proving the
+ // RMLUi -> wlr_scene bridge. When false (default), behaviour is
+ // exactly slice-2. If the spike cannot start (e.g. no font, no GL),
+ // it disables itself gracefully and the server runs as if false.
+ bool ui_spike = false;
};
// Creates the display, backend, renderer, allocator, scene, xdg-shell,
@@ -43,6 +51,18 @@ public:
// Stops run(). Safe from within event handlers.
void terminate();
+ // Frames the slice-3 spike bridge has submitted to the scene so far.
+ // A probe for tests, removed with the spike surface. Returns 0 when
+ // ui_spike is false or the spike disabled itself. Single-thread only.
+ [[nodiscard]] auto ui_spike_frame_count() const -> int;
+
+ // Orientation self-check of the spike's submitted buffer (slice-3 probe,
+ // removed with the spike surface). The document carries distinctive top
+ // and bottom bands; returns +1 if the buffer is upright (top band in the
+ // top rows), -1 if vertically flipped, 0 if indeterminate (disabled, no
+ // frame yet, or not the CPU-readback path). Single-thread only.
+ [[nodiscard]] auto ui_spike_orientation() const -> int;
+
// Opaque to consumers; defined in src/ (kernel-private state).
struct Impl;
diff --git a/packages/kernel/include/unbox/kernel/wlr.hpp b/packages/kernel/include/unbox/kernel/wlr.hpp
index 92051ba..51dd6fb 100644
--- a/packages/kernel/include/unbox/kernel/wlr.hpp
+++ b/packages/kernel/include/unbox/kernel/wlr.hpp
@@ -19,7 +19,19 @@ extern "C" {
#define static
#include <wlr/backend.h>
#include <wlr/render/allocator.h>
+// Slice-3 spike (RMLUi -> wlr_scene bridge): EGL/dmabuf, the GLES2 renderer's
+// EGL accessors, buffer (dmabuf + data-ptr access for the shm fallback),
+// swapchain, and DRM format sets. Re-audited the static-blanking gotcha for
+// these: all are plain declarations; egl.h pulls in <EGL/egl*.h> which have
+// no `static` tokens outside comments (verified). No header-inline function
+// with a function-local `static` is introduced.
+#include <wlr/render/drm_format_set.h>
+#include <wlr/render/egl.h>
+#include <wlr/render/gles2.h>
+#include <wlr/render/swapchain.h>
#include <wlr/render/wlr_renderer.h>
+// Producer-side interface for the spike's custom data-ptr wlr_buffer (Plan B).
+#include <wlr/interfaces/wlr_buffer.h>
#include <wlr/types/wlr_compositor.h>
#include <wlr/types/wlr_cursor.h>
#include <wlr/types/wlr_data_device.h>
@@ -30,6 +42,7 @@ extern "C" {
#include <wlr/types/wlr_pointer.h>
#include <wlr/types/wlr_scene.h>
#include <wlr/types/wlr_seat.h>
+#include <wlr/types/wlr_buffer.h>
#include <wlr/types/wlr_subcompositor.h>
#include <wlr/types/wlr_touch.h>
#include <wlr/types/wlr_xcursor_manager.h>
diff --git a/packages/kernel/kernel.md b/packages/kernel/kernel.md
index 18d7c85..24a37c9 100644
--- a/packages/kernel/kernel.md
+++ b/packages/kernel/kernel.md
@@ -7,6 +7,15 @@ only), keyboard/pointer/touch via one wlr_cursor path. Slice-2 keybindings:
Alt+Escape = terminate, Alt+F1 = cycle. Slice 4 splits shell policy out
into extensions.
+Slice-3 state: THE SPIKE landed on **Plan A** (RMLUi -> dmabuf-backed
+wlr_buffer -> wlr_scene_buffer), with Plan B (FBO + glReadPixels into a
+data-ptr wlr_buffer) as a verified runtime fallback. All bridge state is
+private in `src/ui_spike.{hpp,cpp}` + the adapted GLES3 renderer
+`src/rmlui_renderer_gl3.{h,cpp}`. Public surface delta: `Options::ui_spike`
++ `Server::ui_spike_frame_count()` (both TEMPORARY, replaced by the real ui
+substrate in slice 4+). Driven from the output frame handler; renders only
+when `ui_spike != nullptr`. Host-bin does NOT yet wire the Option.
+
Gotchas the headers can't express:
- **`wlr.hpp` blanks `static` around the wlr includes.** wlroots headers
@@ -32,3 +41,73 @@ Gotchas the headers can't express:
surface-local motion coords; a surface moving mid-touch (interactive
grab) skews them. Acceptable until slice 5's input routing.
- Everything runs on the single `wl_event_loop` thread.
+
+Slice-3 spike gotchas (EGL/dmabuf — read before touching `ui_spike.cpp`):
+
+- **The sibling GLES 3.2 context shares the EGLDisplay, NOT GL objects.**
+ `eglCreateContext` is called with `share_context = EGL_NO_CONTEXT` on
+ `wlr_egl_get_display(wlr_gles2_renderer_get_egl(renderer))`. Buffers cross
+ the boundary ONLY as dmabuf/EGLImage (Plan A) or CPU copy (Plan B) — never
+ shared GL handles. The wlr renderer's current EGL context/surfaces are
+ saved before `eglMakeCurrent` and restored after every tick/teardown;
+ forgetting the restore corrupts wlr's own rendering.
+- **The context is surfaceless** (`eglMakeCurrent(dpy, EGL_NO_SURFACE,
+ EGL_NO_SURFACE, ctx)`, requires `EGL_KHR_surfaceless_context` — present on
+ crocus). RMLUi's `RmlUi_Renderer_GL3` hardcodes `glBindFramebuffer(0)` in
+ `EndFrame()`; framebuffer 0 is INCOMPLETE in a surfaceless context. The
+ adapted copy adds `SetOutputFramebuffer()` so EndFrame composites into the
+ bridge's own offscreen FBO instead. Re-apply that delta on any RMLUi bump.
+- **GLES path selection is `-DUNBOX_RMLUI_GLES`** (kernel meson.build,
+ scoped to the kernel lib). It mirrors upstream's `__ANDROID__` branch
+ (`#version 320 es`, `<GLES3/gl32.h>`, CLAMP_TO_EDGE, no sRGB framebuffer)
+ WITHOUT defining the `__ANDROID__` builtin (which would poison the whole
+ TU). Four upstream `#if ...__ANDROID__` guards were widened to also test
+ `UNBOX_RMLUI_GLES`; re-audit them on a bump.
+- **Plan A dmabuf import is single-plane LINEAR.** We allocate ARGB8888
+ (FourCC 'AR24') with a LINEAR-only modifier list via
+ `wlr_allocator_create_buffer`, `wlr_buffer_get_dmabuf`, then
+ `eglCreateImageKHR(EGL_LINUX_DMA_BUF_EXT)` + `glEGLImageTargetTexture2DOES`
+ as the FBO color attachment. Preconditions checked at runtime (allocator
+ DMABUF cap, `EGL_EXT_image_dma_buf_import`, the two entrypoints); any miss
+ or an incomplete FBO falls back to Plan B with a log line. Render formats
+ are NOT public in wlroots 0.20 (`wlr_renderer_get_render_formats` is
+ private; only `get_texture_formats` is exported), so we pick the format by
+ hand — revisit if a future GPU rejects linear ARGB8888 as a render target.
+- **Submission sync is `glFinish()` (spike fidelity), not a fence.** Plan A
+ must ensure GL writes land before the compositor samples the shared
+ dmabuf. The real substrate should use an EGL fence
+ (`EGL_KHR_fence_sync` is advertised) instead of the full pipeline stall.
+- **Plan B's wlr_buffer is a custom `WLR_BUFFER_CAP_DATA_PTR` impl**
+ (`ShmBuffer` wrapping a `std::vector`, via `<wlr/interfaces/wlr_buffer.h>`).
+ RMLUi outputs premultiplied RGBA8 (R,G,B,A byte order); the buffer is
+ FourCC 'AR24' = little-endian {B,G,R,A}, so the copy swaps R<->B. The
+ alpha is already premultiplied, which wlroots expects.
+- **`UNBOX_UI_SPIKE_FORCE_SHM=1`** forces the Plan-B path even where Plan A
+ works — kept as fallback-test instrumentation; harmless in production.
+- **Headless (pixman) disables the spike**: no gles2 renderer ⇒ no
+ EGLDisplay ⇒ `start_ui_spike()` no-ops. Headless+gles2 (render node
+ present) DOES exercise Plan A — verified.
+- **GL framebuffer origin is bottom-left; wlr_buffer scan-out is top-first.**
+ RMLUi already maps document-y=0 to the GL framebuffer top via
+ `ProjectOrtho(0,w,h,0,...)`, but reading the FBO out (glReadPixels, Plan B)
+ or scanning out the dmabuf (Plan A) yields row 0 = GL bottom = document
+ bottom ⇒ the whole document composited **upside-down** (caught by grim).
+ Fix: the adapted renderer's `EndFrame()` flips V on the FINAL composite
+ into the output FBO when `SetOutputFramebuffer(fbo, /*flip_y=*/true)`
+ (vertex-uv flip on the passthrough fullscreen quad). Chosen over a scene
+ transform or a flipped projection because (a) it makes the SUBMITTED buffer
+ genuinely top-first — so the orientation assertion can read it back
+ directly and so display == document coords, leaving pointer input
+ un-transformed (an on-screen hover hits the button in document space); and
+ (b) it is ONE localized change that applies identically to Plan A and Plan
+ B and needs no matching scissor/clip-rect change (scissor only affects
+ intermediate layer rendering, not the final composite). Re-apply on an
+ RMLUi bump alongside the `SetOutputFramebuffer` delta. NOTE: a flip done as
+ a display-only transform would have left on-screen hit-testing wrong even
+ while a document-space input test passed — verify display+input together.
+- **Orientation regression guard**: the spike document carries distinctive
+ full-width solid bands at its top (`#18e0a0`) and bottom (`#e09018`) edges.
+ `UiSpike::check_orientation()` (exposed as `Server::ui_spike_orientation()`)
+ inspects the Plan-B readback and returns +1 upright / -1 flipped / 0
+ indeterminate. The `kernel` suite asserts it is never -1 (and ==1 when the
+ bridge ran). Position-aware, not just color-aware — a flip can't slip past.
diff --git a/packages/kernel/meson.build b/packages/kernel/meson.build
index e18d656..2c7b971 100644
--- a/packages/kernel/meson.build
+++ b/packages/kernel/meson.build
@@ -2,14 +2,27 @@
kernel_inc = include_directories('include')
+# Slice-3 spike: native GLES 3.2 + EGL for the RMLUi -> wlr_scene bridge.
+# Already surfaced to the user and approved (prompts/kernel.md). The kernel
+# owns GL; these are kernel-private and do NOT propagate to consumers.
+egl_dep = dependency('egl')
+glesv2_dep = dependency('glesv2')
+
+# UNBOX_RMLUI_GLES selects the native GLES 3.2 path in the adapted RmlUi GL3
+# renderer (src/rmlui_renderer_gl3.cpp) without poisoning the TU with the
+# __ANDROID__ builtin. Scoped to this library only.
kernel_lib = static_library(
'unbox-kernel',
'src/kernel.cpp',
'src/server.cpp',
'src/toplevel.cpp',
'src/input.cpp',
+ 'src/ui_spike.cpp',
+ 'src/rmlui_renderer_gl3.cpp',
+ cpp_args: ['-DUNBOX_RMLUI_GLES'],
include_directories: kernel_inc,
- dependencies: [wlroots_dep, wayland_server_dep, xkbcommon_dep, rmlui_dep],
+ dependencies: [wlroots_dep, wayland_server_dep, xkbcommon_dep, rmlui_dep,
+ egl_dep, glesv2_dep],
)
# What consumers get. wlroots/wayland propagate because wlr.hpp is a public
diff --git a/packages/kernel/src/input.cpp b/packages/kernel/src/input.cpp
index 93bee74..7095154 100644
--- a/packages/kernel/src/input.cpp
+++ b/packages/kernel/src/input.cpp
@@ -190,6 +190,20 @@ void Server::Impl::process_cursor_motion(std::uint32_t time_msec) {
return;
}
+ // Slice-3 spike input proof (NOT the slice-5 routing contract): if the
+ // cursor is over the spike node, forward surface-local coords to RmlUi so
+ // the document's button reacts to hover. Crude and private.
+ if (ui_spike != nullptr) {
+ if (wlr_scene_node* spike = ui_spike->node()) {
+ int nx = 0;
+ int ny = 0;
+ wlr_scene_node_coords(spike, &nx, &ny);
+ const double sx = cursor->x - nx;
+ const double sy = cursor->y - ny;
+ ui_spike->on_pointer_motion(sx, sy);
+ }
+ }
+
double sx = 0;
double sy = 0;
wlr_surface* surface = nullptr;
@@ -221,6 +235,21 @@ void Server::Impl::attach_cursor_handlers() {
cursor_button.connect(cursor->events.button, [this](void* data) {
const auto* event = static_cast<wlr_pointer_button_event*>(data);
wlr_seat_pointer_notify_button(seat, event->time_msec, event->button, event->state);
+
+ // Slice-3 spike input proof: forward clicks over the spike node to
+ // RmlUi so its button reacts to press/release. Crude and private.
+ if (ui_spike != nullptr) {
+ if (wlr_scene_node* spike = ui_spike->node()) {
+ int nx = 0;
+ int ny = 0;
+ wlr_scene_node_coords(spike, &nx, &ny);
+ if (wlr_scene_node_at(spike, cursor->x, cursor->y, nullptr, nullptr) != nullptr) {
+ ui_spike->on_pointer_button(event->state ==
+ WL_POINTER_BUTTON_STATE_PRESSED);
+ }
+ }
+ }
+
if (event->state == WL_POINTER_BUTTON_STATE_RELEASED) {
reset_cursor_mode();
} else {
diff --git a/packages/kernel/src/rmlui_renderer_gl3.cpp b/packages/kernel/src/rmlui_renderer_gl3.cpp
new file mode 100644
index 0000000..7e192e3
--- /dev/null
+++ b/packages/kernel/src/rmlui_renderer_gl3.cpp
@@ -0,0 +1,2197 @@
+// Adapted from subprojects/RmlUi-6.2/Backends/RmlUi_Renderer_GL3.{h,cpp}
+// (slice-3 spike, prompts/kernel.md item 5). Two deltas from upstream:
+// 1. UNBOX_RMLUI_GLES selects the native GLES 3.2 header/shader path
+// WITHOUT defining the compiler builtin __ANDROID__ (which would
+// poison the whole TU). The branch is otherwise byte-identical to
+// upstream's __ANDROID__ branch.
+// 2. EndFrame() blits into a settable output framebuffer (default 0)
+// instead of the hardcoded default framebuffer, so the bridge can
+// direct RmlUi's result into its own offscreen FBO. See
+// SetOutputFramebuffer() / output_framebuffer.
+// Everything else is verbatim upstream; do not "improve" it.
+#include "rmlui_renderer_gl3.h"
+#include <RmlUi/Core/Core.h>
+#include <RmlUi/Core/DecorationTypes.h>
+#include <RmlUi/Core/FileInterface.h>
+#include <RmlUi/Core/Geometry.h>
+#include <RmlUi/Core/Log.h>
+#include <RmlUi/Core/MeshUtilities.h>
+#include <RmlUi/Core/Platform.h>
+#include <RmlUi/Core/SystemInterface.h>
+#include <algorithm>
+#include <string.h>
+
+#if defined RMLUI_PLATFORM_WIN32_NATIVE
+ // function call missing argument list
+ #pragma warning(disable : 4551)
+ // unreferenced local function has been removed
+ #pragma warning(disable : 4505)
+#endif
+
+#if defined UNBOX_RMLUI_GLES
+ // unbox: native GLES 3.2 path (HD 4400 / crocus, hardware-verified).
+ #define RMLUI_SHADER_HEADER_VERSION "#version 320 es\nprecision highp float;\n"
+ #include <GLES3/gl32.h>
+#elif defined RMLUI_PLATFORM_EMSCRIPTEN
+ #define RMLUI_SHADER_HEADER_VERSION "#version 300 es\nprecision highp float;\n"
+ #include <GLES3/gl3.h>
+#elif defined __ANDROID__
+ #define RMLUI_SHADER_HEADER_VERSION "#version 320 es\nprecision highp float;\n"
+ #include <GLES3/gl32.h>
+#elif defined RMLUI_GL3_CUSTOM_LOADER
+ #define RMLUI_SHADER_HEADER_VERSION "#version 330\n"
+ #include RMLUI_GL3_CUSTOM_LOADER
+#else
+ #define RMLUI_SHADER_HEADER_VERSION "#version 330\n"
+ #define GLAD_GL_IMPLEMENTATION
+ #include "RmlUi_Include_GL3.h"
+#endif
+
+// Determines the anti-aliasing quality when creating layers. Enables better-looking visuals, especially when transforms are applied.
+#ifndef RMLUI_NUM_MSAA_SAMPLES
+ #define RMLUI_NUM_MSAA_SAMPLES 2
+#endif
+
+#define MAX_NUM_STOPS 16
+#define BLUR_SIZE 7
+#define BLUR_NUM_WEIGHTS ((BLUR_SIZE + 1) / 2)
+
+#define RMLUI_STRINGIFY_IMPL(x) #x
+#define RMLUI_STRINGIFY(x) RMLUI_STRINGIFY_IMPL(x)
+
+#define RMLUI_SHADER_HEADER \
+ RMLUI_SHADER_HEADER_VERSION "#define MAX_NUM_STOPS " RMLUI_STRINGIFY(MAX_NUM_STOPS) "\n#line " RMLUI_STRINGIFY(__LINE__) "\n"
+
+static const char* shader_vert_main = RMLUI_SHADER_HEADER R"(
+uniform vec2 _translate;
+uniform mat4 _transform;
+
+in vec2 inPosition;
+in vec4 inColor0;
+in vec2 inTexCoord0;
+
+out vec2 fragTexCoord;
+out vec4 fragColor;
+
+void main() {
+ fragTexCoord = inTexCoord0;
+ fragColor = inColor0;
+
+ vec2 translatedPos = inPosition + _translate;
+ vec4 outPos = _transform * vec4(translatedPos, 0.0, 1.0);
+
+ gl_Position = outPos;
+}
+)";
+static const char* shader_frag_texture = RMLUI_SHADER_HEADER R"(
+uniform sampler2D _tex;
+in vec2 fragTexCoord;
+in vec4 fragColor;
+
+out vec4 finalColor;
+
+void main() {
+ vec4 texColor = texture(_tex, fragTexCoord);
+ finalColor = fragColor * texColor;
+}
+)";
+static const char* shader_frag_color = RMLUI_SHADER_HEADER R"(
+in vec2 fragTexCoord;
+in vec4 fragColor;
+
+out vec4 finalColor;
+
+void main() {
+ finalColor = fragColor;
+}
+)";
+
+enum class ShaderGradientFunction { Linear, Radial, Conic, RepeatingLinear, RepeatingRadial, RepeatingConic }; // Must match shader definitions below.
+
+static const char* shader_frag_gradient = RMLUI_SHADER_HEADER R"(
+#define LINEAR 0
+#define RADIAL 1
+#define CONIC 2
+#define REPEATING_LINEAR 3
+#define REPEATING_RADIAL 4
+#define REPEATING_CONIC 5
+#define PI 3.14159265
+
+uniform int _func; // one of the above definitions
+uniform vec2 _p; // linear: starting point, radial: center, conic: center
+uniform vec2 _v; // linear: vector to ending point, radial: 2d curvature (inverse radius), conic: angled unit vector
+uniform vec4 _stop_colors[MAX_NUM_STOPS];
+uniform float _stop_positions[MAX_NUM_STOPS]; // normalized, 0 -> starting point, 1 -> ending point
+uniform int _num_stops;
+
+in vec2 fragTexCoord;
+in vec4 fragColor;
+out vec4 finalColor;
+
+vec4 mix_stop_colors(float t) {
+ vec4 color = _stop_colors[0];
+
+ for (int i = 1; i < _num_stops; i++)
+ color = mix(color, _stop_colors[i], smoothstep(_stop_positions[i-1], _stop_positions[i], t));
+
+ return color;
+}
+
+void main() {
+ float t = 0.0;
+
+ if (_func == LINEAR || _func == REPEATING_LINEAR)
+ {
+ float dist_square = dot(_v, _v);
+ vec2 V = fragTexCoord - _p;
+ t = dot(_v, V) / dist_square;
+ }
+ else if (_func == RADIAL || _func == REPEATING_RADIAL)
+ {
+ vec2 V = fragTexCoord - _p;
+ t = length(_v * V);
+ }
+ else if (_func == CONIC || _func == REPEATING_CONIC)
+ {
+ mat2 R = mat2(_v.x, -_v.y, _v.y, _v.x);
+ vec2 V = R * (fragTexCoord - _p);
+ t = 0.5 + atan(-V.x, V.y) / (2.0 * PI);
+ }
+
+ if (_func == REPEATING_LINEAR || _func == REPEATING_RADIAL || _func == REPEATING_CONIC)
+ {
+ float t0 = _stop_positions[0];
+ float t1 = _stop_positions[_num_stops - 1];
+ t = t0 + mod(t - t0, t1 - t0);
+ }
+
+ finalColor = fragColor * mix_stop_colors(t);
+}
+)";
+
+// "Creation" by Danilo Guanabara, based on: https://www.shadertoy.com/view/XsXXDn
+static const char* shader_frag_creation = RMLUI_SHADER_HEADER R"(
+uniform float _value;
+uniform vec2 _dimensions;
+
+in vec2 fragTexCoord;
+in vec4 fragColor;
+out vec4 finalColor;
+
+void main() {
+ float t = _value;
+ vec3 c;
+ float l;
+ for (int i = 0; i < 3; i++) {
+ vec2 p = fragTexCoord;
+ vec2 uv = p;
+ p -= .5;
+ p.x *= _dimensions.x / _dimensions.y;
+ float z = t + float(i) * .07;
+ l = length(p);
+ uv += p / l * (sin(z) + 1.) * abs(sin(l * 9. - z - z));
+ c[i] = .01 / length(mod(uv, 1.) - .5);
+ }
+ finalColor = vec4(c / l, fragColor.a);
+}
+)";
+
+static const char* shader_vert_passthrough = RMLUI_SHADER_HEADER R"(
+in vec2 inPosition;
+in vec2 inTexCoord0;
+
+out vec2 fragTexCoord;
+
+void main() {
+ fragTexCoord = inTexCoord0;
+ gl_Position = vec4(inPosition, 0.0, 1.0);
+}
+)";
+static const char* shader_frag_passthrough = RMLUI_SHADER_HEADER R"(
+uniform sampler2D _tex;
+in vec2 fragTexCoord;
+out vec4 finalColor;
+
+void main() {
+ finalColor = texture(_tex, fragTexCoord);
+}
+)";
+static const char* shader_frag_color_matrix = RMLUI_SHADER_HEADER R"(
+uniform sampler2D _tex;
+uniform mat4 _color_matrix;
+
+in vec2 fragTexCoord;
+out vec4 finalColor;
+
+void main() {
+ // The general case uses a 4x5 color matrix for full rgba transformation, plus a constant term with the last column.
+ // However, we only consider the case of rgb transformations. Thus, we could in principle use a 3x4 matrix, but we
+ // keep the alpha row for simplicity.
+ // In the general case we should do the matrix transformation in non-premultiplied space. However, without alpha
+ // transformations, we can do it directly in premultiplied space to avoid the extra division and multiplication
+ // steps. In this space, the constant term needs to be multiplied by the alpha value, instead of unity.
+ vec4 texColor = texture(_tex, fragTexCoord);
+ vec3 transformedColor = vec3(_color_matrix * texColor);
+ finalColor = vec4(transformedColor, texColor.a);
+}
+)";
+static const char* shader_frag_blend_mask = RMLUI_SHADER_HEADER R"(
+uniform sampler2D _tex;
+uniform sampler2D _texMask;
+
+in vec2 fragTexCoord;
+out vec4 finalColor;
+
+void main() {
+ vec4 texColor = texture(_tex, fragTexCoord);
+ float maskAlpha = texture(_texMask, fragTexCoord).a;
+ finalColor = texColor * maskAlpha;
+}
+)";
+
+#define RMLUI_SHADER_BLUR_HEADER \
+ RMLUI_SHADER_HEADER "\n#define BLUR_SIZE " RMLUI_STRINGIFY(BLUR_SIZE) "\n#define BLUR_NUM_WEIGHTS " RMLUI_STRINGIFY(BLUR_NUM_WEIGHTS)
+
+static const char* shader_vert_blur = RMLUI_SHADER_BLUR_HEADER R"(
+uniform vec2 _texelOffset;
+
+in vec3 inPosition;
+in vec2 inTexCoord0;
+
+out vec2 fragTexCoord[BLUR_SIZE];
+
+void main() {
+ for(int i = 0; i < BLUR_SIZE; i++)
+ fragTexCoord[i] = inTexCoord0 - float(i - BLUR_NUM_WEIGHTS + 1) * _texelOffset;
+ gl_Position = vec4(inPosition, 1.0);
+}
+)";
+static const char* shader_frag_blur = RMLUI_SHADER_BLUR_HEADER R"(
+uniform sampler2D _tex;
+uniform float _weights[BLUR_NUM_WEIGHTS];
+uniform vec2 _texCoordMin;
+uniform vec2 _texCoordMax;
+
+in vec2 fragTexCoord[BLUR_SIZE];
+out vec4 finalColor;
+
+void main() {
+ vec4 color = vec4(0.0, 0.0, 0.0, 0.0);
+ for(int i = 0; i < BLUR_SIZE; i++)
+ {
+ vec2 in_region = step(_texCoordMin, fragTexCoord[i]) * step(fragTexCoord[i], _texCoordMax);
+ color += texture(_tex, fragTexCoord[i]) * in_region.x * in_region.y * _weights[abs(i - BLUR_NUM_WEIGHTS + 1)];
+ }
+ finalColor = color;
+}
+)";
+static const char* shader_frag_drop_shadow = RMLUI_SHADER_HEADER R"(
+uniform sampler2D _tex;
+uniform vec2 _texCoordMin;
+uniform vec2 _texCoordMax;
+uniform vec4 _color;
+
+in vec2 fragTexCoord;
+out vec4 finalColor;
+
+void main() {
+ vec2 in_region = step(_texCoordMin, fragTexCoord) * step(fragTexCoord, _texCoordMax);
+ finalColor = texture(_tex, fragTexCoord).a * in_region.x * in_region.y * _color;
+}
+)";
+
+enum class ProgramId {
+ None,
+ Color,
+ Texture,
+ Gradient,
+ Creation,
+ Passthrough,
+ ColorMatrix,
+ BlendMask,
+ Blur,
+ DropShadow,
+ Count,
+};
+enum class VertShaderId {
+ Main,
+ Passthrough,
+ Blur,
+ Count,
+};
+enum class FragShaderId {
+ Color,
+ Texture,
+ Gradient,
+ Creation,
+ Passthrough,
+ ColorMatrix,
+ BlendMask,
+ Blur,
+ DropShadow,
+ Count,
+};
+enum class UniformId {
+ Translate,
+ Transform,
+ Tex,
+ Color,
+ ColorMatrix,
+ TexelOffset,
+ TexCoordMin,
+ TexCoordMax,
+ TexMask,
+ Weights,
+ Func,
+ P,
+ V,
+ StopColors,
+ StopPositions,
+ NumStops,
+ Value,
+ Dimensions,
+ Count,
+};
+
+namespace Gfx {
+
+static const char* const program_uniform_names[(size_t)UniformId::Count] = {"_translate", "_transform", "_tex", "_color", "_color_matrix",
+ "_texelOffset", "_texCoordMin", "_texCoordMax", "_texMask", "_weights[0]", "_func", "_p", "_v", "_stop_colors[0]", "_stop_positions[0]",
+ "_num_stops", "_value", "_dimensions"};
+
+enum class VertexAttribute { Position, Color0, TexCoord0, Count };
+static const char* const vertex_attribute_names[(size_t)VertexAttribute::Count] = {"inPosition", "inColor0", "inTexCoord0"};
+
+struct VertShaderDefinition {
+ VertShaderId id;
+ const char* name_str;
+ const char* code_str;
+};
+struct FragShaderDefinition {
+ FragShaderId id;
+ const char* name_str;
+ const char* code_str;
+};
+struct ProgramDefinition {
+ ProgramId id;
+ const char* name_str;
+ VertShaderId vert_shader;
+ FragShaderId frag_shader;
+};
+
+// clang-format off
+static const VertShaderDefinition vert_shader_definitions[] = {
+ {VertShaderId::Main, "main", shader_vert_main},
+ {VertShaderId::Passthrough, "passthrough", shader_vert_passthrough},
+ {VertShaderId::Blur, "blur", shader_vert_blur},
+};
+static const FragShaderDefinition frag_shader_definitions[] = {
+ {FragShaderId::Color, "color", shader_frag_color},
+ {FragShaderId::Texture, "texture", shader_frag_texture},
+ {FragShaderId::Gradient, "gradient", shader_frag_gradient},
+ {FragShaderId::Creation, "creation", shader_frag_creation},
+ {FragShaderId::Passthrough, "passthrough", shader_frag_passthrough},
+ {FragShaderId::ColorMatrix, "color_matrix", shader_frag_color_matrix},
+ {FragShaderId::BlendMask, "blend_mask", shader_frag_blend_mask},
+ {FragShaderId::Blur, "blur", shader_frag_blur},
+ {FragShaderId::DropShadow, "drop_shadow", shader_frag_drop_shadow},
+};
+static const ProgramDefinition program_definitions[] = {
+ {ProgramId::Color, "color", VertShaderId::Main, FragShaderId::Color},
+ {ProgramId::Texture, "texture", VertShaderId::Main, FragShaderId::Texture},
+ {ProgramId::Gradient, "gradient", VertShaderId::Main, FragShaderId::Gradient},
+ {ProgramId::Creation, "creation", VertShaderId::Main, FragShaderId::Creation},
+ {ProgramId::Passthrough, "passthrough", VertShaderId::Passthrough, FragShaderId::Passthrough},
+ {ProgramId::ColorMatrix, "color_matrix", VertShaderId::Passthrough, FragShaderId::ColorMatrix},
+ {ProgramId::BlendMask, "blend_mask", VertShaderId::Passthrough, FragShaderId::BlendMask},
+ {ProgramId::Blur, "blur", VertShaderId::Blur, FragShaderId::Blur},
+ {ProgramId::DropShadow, "drop_shadow", VertShaderId::Passthrough, FragShaderId::DropShadow},
+};
+// clang-format on
+
+template <typename T, typename Enum>
+class EnumArray {
+public:
+ const T& operator[](Enum id) const
+ {
+ RMLUI_ASSERT((size_t)id < (size_t)Enum::Count);
+ return ids[size_t(id)];
+ }
+ T& operator[](Enum id)
+ {
+ RMLUI_ASSERT((size_t)id < (size_t)Enum::Count);
+ return ids[size_t(id)];
+ }
+ auto begin() const { return ids.begin(); }
+ auto end() const { return ids.end(); }
+
+private:
+ Rml::Array<T, (size_t)Enum::Count> ids = {};
+};
+
+using Programs = EnumArray<GLuint, ProgramId>;
+using VertShaders = EnumArray<GLuint, VertShaderId>;
+using FragShaders = EnumArray<GLuint, FragShaderId>;
+
+class Uniforms {
+public:
+ GLint Get(ProgramId id, UniformId uniform) const
+ {
+ auto it = map.find(ToKey(id, uniform));
+ if (it != map.end())
+ return it->second;
+ return -1;
+ }
+ void Insert(ProgramId id, UniformId uniform, GLint location) { map[ToKey(id, uniform)] = location; }
+
+private:
+ using Key = uint64_t;
+ Key ToKey(ProgramId id, UniformId uniform) const { return (static_cast<Key>(id) << 32) | static_cast<Key>(uniform); }
+ Rml::UnorderedMap<Key, GLint> map;
+};
+
+struct ProgramData {
+ Programs programs;
+ VertShaders vert_shaders;
+ FragShaders frag_shaders;
+ Uniforms uniforms;
+};
+
+struct CompiledGeometryData {
+ GLuint vao;
+ GLuint vbo;
+ GLuint ibo;
+ GLsizei draw_count;
+};
+
+struct FramebufferData {
+ int width, height;
+ GLuint framebuffer;
+ GLuint color_tex_buffer;
+ GLuint color_render_buffer;
+ GLuint depth_stencil_buffer;
+ bool owns_depth_stencil_buffer;
+};
+
+enum class FramebufferAttachment { None, DepthStencil };
+
+static void CheckGLError(const char* operation_name)
+{
+#ifdef RMLUI_DEBUG
+ GLenum error_code = glGetError();
+ if (error_code != GL_NO_ERROR)
+ {
+ static const Rml::Pair<GLenum, const char*> error_names[] = {{GL_INVALID_ENUM, "GL_INVALID_ENUM"}, {GL_INVALID_VALUE, "GL_INVALID_VALUE"},
+ {GL_INVALID_OPERATION, "GL_INVALID_OPERATION"}, {GL_OUT_OF_MEMORY, "GL_OUT_OF_MEMORY"}};
+ const char* error_str = "''";
+ for (auto& err : error_names)
+ {
+ if (err.first == error_code)
+ {
+ error_str = err.second;
+ break;
+ }
+ }
+ Rml::Log::Message(Rml::Log::LT_ERROR, "OpenGL error during %s. Error code 0x%x (%s).", operation_name, error_code, error_str);
+ }
+#endif
+ (void)operation_name;
+}
+
+// Create the shader, 'shader_type' is either GL_VERTEX_SHADER or GL_FRAGMENT_SHADER.
+static bool CreateShader(GLuint& out_shader_id, GLenum shader_type, const char* code_string)
+{
+ RMLUI_ASSERT(shader_type == GL_VERTEX_SHADER || shader_type == GL_FRAGMENT_SHADER);
+
+ GLuint id = glCreateShader(shader_type);
+ glShaderSource(id, 1, (const GLchar**)&code_string, NULL);
+ glCompileShader(id);
+
+ GLint status = 0;
+ glGetShaderiv(id, GL_COMPILE_STATUS, &status);
+ if (status == GL_FALSE)
+ {
+ GLint info_log_length = 0;
+ glGetShaderiv(id, GL_INFO_LOG_LENGTH, &info_log_length);
+ char* info_log_string = new char[info_log_length + 1];
+ glGetShaderInfoLog(id, info_log_length, NULL, info_log_string);
+
+ Rml::Log::Message(Rml::Log::LT_ERROR, "Compile failure in OpenGL shader: %s", info_log_string);
+ delete[] info_log_string;
+ glDeleteShader(id);
+ return false;
+ }
+
+ CheckGLError("CreateShader");
+
+ out_shader_id = id;
+ return true;
+}
+
+static bool CreateProgram(GLuint& out_program, Uniforms& inout_uniform_map, ProgramId program_id, GLuint vertex_shader, GLuint fragment_shader)
+{
+ GLuint id = glCreateProgram();
+ RMLUI_ASSERT(id);
+
+ for (GLuint i = 0; i < (GLuint)VertexAttribute::Count; i++)
+ glBindAttribLocation(id, i, vertex_attribute_names[i]);
+
+ CheckGLError("BindAttribLocations");
+
+ glAttachShader(id, vertex_shader);
+ glAttachShader(id, fragment_shader);
+
+ glLinkProgram(id);
+
+ glDetachShader(id, vertex_shader);
+ glDetachShader(id, fragment_shader);
+
+ GLint status = 0;
+ glGetProgramiv(id, GL_LINK_STATUS, &status);
+ if (status == GL_FALSE)
+ {
+ GLint info_log_length = 0;
+ glGetProgramiv(id, GL_INFO_LOG_LENGTH, &info_log_length);
+ char* info_log_string = new char[info_log_length + 1];
+ glGetProgramInfoLog(id, info_log_length, NULL, info_log_string);
+
+ Rml::Log::Message(Rml::Log::LT_ERROR, "OpenGL program linking failure: %s", info_log_string);
+ delete[] info_log_string;
+ glDeleteProgram(id);
+ return false;
+ }
+
+ out_program = id;
+
+ // Make a lookup table for the uniform locations.
+ GLint num_active_uniforms = 0;
+ glGetProgramiv(id, GL_ACTIVE_UNIFORMS, &num_active_uniforms);
+
+ constexpr size_t name_size = 64;
+ GLchar name_buf[name_size] = "";
+ for (int unif = 0; unif < num_active_uniforms; ++unif)
+ {
+ GLint array_size = 0;
+ GLenum type = 0;
+ GLsizei actual_length = 0;
+ glGetActiveUniform(id, unif, name_size, &actual_length, &array_size, &type, name_buf);
+ GLint location = glGetUniformLocation(id, name_buf);
+
+ // See if we have the name in our pre-defined name list.
+ UniformId program_uniform = UniformId::Count;
+ for (int i = 0; i < (int)UniformId::Count; i++)
+ {
+ const char* uniform_name = program_uniform_names[i];
+ if (strcmp(name_buf, uniform_name) == 0)
+ {
+ program_uniform = (UniformId)i;
+ break;
+ }
+ }
+
+ if ((size_t)program_uniform < (size_t)UniformId::Count)
+ {
+ inout_uniform_map.Insert(program_id, program_uniform, location);
+ }
+ else
+ {
+ Rml::Log::Message(Rml::Log::LT_ERROR, "OpenGL program uses unknown uniform '%s'.", name_buf);
+ return false;
+ }
+ }
+
+ CheckGLError("CreateProgram");
+
+ return true;
+}
+
+static bool CreateFramebuffer(FramebufferData& out_fb, int width, int height, int samples, FramebufferAttachment attachment,
+ GLuint shared_depth_stencil_buffer)
+{
+#if defined(UNBOX_RMLUI_GLES) || defined(RMLUI_PLATFORM_EMSCRIPTEN) || defined(__ANDROID__)
+ constexpr GLint wrap_mode = GL_CLAMP_TO_EDGE;
+#else
+ constexpr GLint wrap_mode = GL_CLAMP_TO_BORDER; // GL_REPEAT GL_MIRRORED_REPEAT GL_CLAMP_TO_EDGE
+#endif
+
+ constexpr GLenum color_format = GL_RGBA8; // GL_RGBA8 GL_SRGB8_ALPHA8 GL_RGBA16F
+ constexpr GLint min_mag_filter = GL_LINEAR; // GL_NEAREST
+ const Rml::Colourf border_color(0.f, 0.f);
+
+ GLuint framebuffer = 0;
+ glGenFramebuffers(1, &framebuffer);
+ glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
+
+ GLuint color_tex_buffer = 0;
+ GLuint color_render_buffer = 0;
+ if (samples > 0)
+ {
+ glGenRenderbuffers(1, &color_render_buffer);
+ glBindRenderbuffer(GL_RENDERBUFFER, color_render_buffer);
+ glRenderbufferStorageMultisample(GL_RENDERBUFFER, samples, color_format, width, height);
+ glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, color_render_buffer);
+ }
+ else
+ {
+ glGenTextures(1, &color_tex_buffer);
+ glBindTexture(GL_TEXTURE_2D, color_tex_buffer);
+ glTexImage2D(GL_TEXTURE_2D, 0, color_format, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
+
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, min_mag_filter);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, min_mag_filter);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap_mode);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap_mode);
+#if !defined(UNBOX_RMLUI_GLES) && !defined(RMLUI_PLATFORM_EMSCRIPTEN) && !defined(__ANDROID__)
+ glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, &border_color[0]);
+#endif
+
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color_tex_buffer, 0);
+ }
+
+ // Create depth/stencil buffer storage attachment.
+ GLuint depth_stencil_buffer = 0;
+ if (attachment != FramebufferAttachment::None)
+ {
+ if (shared_depth_stencil_buffer)
+ {
+ // Share depth/stencil buffer
+ depth_stencil_buffer = shared_depth_stencil_buffer;
+ }
+ else
+ {
+ // Create new depth/stencil buffer
+ glGenRenderbuffers(1, &depth_stencil_buffer);
+ glBindRenderbuffer(GL_RENDERBUFFER, depth_stencil_buffer);
+
+ glRenderbufferStorageMultisample(GL_RENDERBUFFER, samples, GL_DEPTH24_STENCIL8, width, height);
+ }
+
+ glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, depth_stencil_buffer);
+ }
+
+ const GLuint framebuffer_status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
+ if (framebuffer_status != GL_FRAMEBUFFER_COMPLETE)
+ {
+ Rml::Log::Message(Rml::Log::LT_ERROR, "OpenGL framebuffer could not be generated. Error code %x.", framebuffer_status);
+ return false;
+ }
+
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ glBindTexture(GL_TEXTURE_2D, 0);
+ glBindRenderbuffer(GL_RENDERBUFFER, 0);
+
+ CheckGLError("CreateFramebuffer");
+
+ out_fb = {};
+ out_fb.width = width;
+ out_fb.height = height;
+ out_fb.framebuffer = framebuffer;
+ out_fb.color_tex_buffer = color_tex_buffer;
+ out_fb.color_render_buffer = color_render_buffer;
+ out_fb.depth_stencil_buffer = depth_stencil_buffer;
+ out_fb.owns_depth_stencil_buffer = !shared_depth_stencil_buffer;
+
+ return true;
+}
+
+static void DestroyFramebuffer(FramebufferData& fb)
+{
+ if (fb.framebuffer)
+ glDeleteFramebuffers(1, &fb.framebuffer);
+ if (fb.color_tex_buffer)
+ glDeleteTextures(1, &fb.color_tex_buffer);
+ if (fb.color_render_buffer)
+ glDeleteRenderbuffers(1, &fb.color_render_buffer);
+ if (fb.owns_depth_stencil_buffer && fb.depth_stencil_buffer)
+ glDeleteRenderbuffers(1, &fb.depth_stencil_buffer);
+ fb = {};
+}
+
+static GLuint CreateTexture(Rml::Span<const Rml::byte> source_data, Rml::Vector2i source_dimensions)
+{
+ GLuint texture_id = 0;
+ glGenTextures(1, &texture_id);
+ if (texture_id == 0)
+ return 0;
+
+ glBindTexture(GL_TEXTURE_2D, texture_id);
+
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, source_dimensions.x, source_dimensions.y, 0, GL_RGBA, GL_UNSIGNED_BYTE, source_data.data());
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
+
+ glBindTexture(GL_TEXTURE_2D, 0);
+
+ return texture_id;
+}
+
+static void BindTexture(const FramebufferData& fb)
+{
+ if (!fb.color_tex_buffer)
+ {
+ RMLUI_ERRORMSG("Only framebuffers with color textures can be bound as textures. This framebuffer probably uses multisampling which needs a "
+ "blit step first.");
+ }
+
+ glBindTexture(GL_TEXTURE_2D, fb.color_tex_buffer);
+}
+
+static bool CreateShaders(ProgramData& data)
+{
+ RMLUI_ASSERT(std::all_of(data.vert_shaders.begin(), data.vert_shaders.end(), [](auto&& value) { return value == 0; }));
+ RMLUI_ASSERT(std::all_of(data.frag_shaders.begin(), data.frag_shaders.end(), [](auto&& value) { return value == 0; }));
+ RMLUI_ASSERT(std::all_of(data.programs.begin(), data.programs.end(), [](auto&& value) { return value == 0; }));
+ auto ReportError = [](const char* type, const char* name) {
+ Rml::Log::Message(Rml::Log::LT_ERROR, "Could not create OpenGL %s: '%s'.", type, name);
+ return false;
+ };
+
+ for (const VertShaderDefinition& def : vert_shader_definitions)
+ {
+ if (!CreateShader(data.vert_shaders[def.id], GL_VERTEX_SHADER, def.code_str))
+ return ReportError("vertex shader", def.name_str);
+ }
+
+ for (const FragShaderDefinition& def : frag_shader_definitions)
+ {
+ if (!CreateShader(data.frag_shaders[def.id], GL_FRAGMENT_SHADER, def.code_str))
+ return ReportError("fragment shader", def.name_str);
+ }
+
+ for (const ProgramDefinition& def : program_definitions)
+ {
+ if (!CreateProgram(data.programs[def.id], data.uniforms, def.id, data.vert_shaders[def.vert_shader], data.frag_shaders[def.frag_shader]))
+ return ReportError("program", def.name_str);
+ }
+
+ glUseProgram(data.programs[ProgramId::BlendMask]);
+ glUniform1i(data.uniforms.Get(ProgramId::BlendMask, UniformId::TexMask), 1);
+
+ glUseProgram(0);
+
+ return true;
+}
+
+static void DestroyShaders(const ProgramData& data)
+{
+ for (GLuint id : data.programs)
+ glDeleteProgram(id);
+
+ for (GLuint id : data.vert_shaders)
+ glDeleteShader(id);
+
+ for (GLuint id : data.frag_shaders)
+ glDeleteShader(id);
+}
+
+} // namespace Gfx
+
+RenderInterface_GL3::RenderInterface_GL3()
+{
+ auto mut_program_data = Rml::MakeUnique<Gfx::ProgramData>();
+ if (Gfx::CreateShaders(*mut_program_data))
+ {
+ program_data = std::move(mut_program_data);
+ Rml::Mesh mesh;
+ Rml::MeshUtilities::GenerateQuad(mesh, Rml::Vector2f(-1), Rml::Vector2f(2), {});
+ fullscreen_quad_geometry = RenderInterface_GL3::CompileGeometry(mesh.vertices, mesh.indices);
+ }
+}
+
+RenderInterface_GL3::~RenderInterface_GL3()
+{
+ if (fullscreen_quad_geometry)
+ {
+ RenderInterface_GL3::ReleaseGeometry(fullscreen_quad_geometry);
+ fullscreen_quad_geometry = {};
+ }
+
+ if (program_data)
+ {
+ Gfx::DestroyShaders(*program_data);
+ program_data.reset();
+ }
+}
+
+void RenderInterface_GL3::SetViewport(int width, int height, int offset_x, int offset_y)
+{
+ viewport_width = Rml::Math::Max(width, 1);
+ viewport_height = Rml::Math::Max(height, 1);
+ viewport_offset_x = offset_x;
+ viewport_offset_y = offset_y;
+ projection = Rml::Matrix4f::ProjectOrtho(0, (float)viewport_width, (float)viewport_height, 0, -10000, 10000);
+}
+
+void RenderInterface_GL3::BeginFrame()
+{
+ RMLUI_ASSERT(viewport_width >= 1 && viewport_height >= 1);
+
+ // Backup GL state.
+ glstate_backup.enable_cull_face = glIsEnabled(GL_CULL_FACE);
+ glstate_backup.enable_blend = glIsEnabled(GL_BLEND);
+ glstate_backup.enable_stencil_test = glIsEnabled(GL_STENCIL_TEST);
+ glstate_backup.enable_scissor_test = glIsEnabled(GL_SCISSOR_TEST);
+ glstate_backup.enable_depth_test = glIsEnabled(GL_DEPTH_TEST);
+
+ glGetIntegerv(GL_VIEWPORT, glstate_backup.viewport);
+ glGetIntegerv(GL_SCISSOR_BOX, glstate_backup.scissor);
+
+ glGetIntegerv(GL_ACTIVE_TEXTURE, &glstate_backup.active_texture);
+
+ glGetIntegerv(GL_STENCIL_CLEAR_VALUE, &glstate_backup.stencil_clear_value);
+ glGetFloatv(GL_COLOR_CLEAR_VALUE, glstate_backup.color_clear_value);
+ glGetBooleanv(GL_COLOR_WRITEMASK, glstate_backup.color_writemask);
+
+ glGetIntegerv(GL_BLEND_EQUATION_RGB, &glstate_backup.blend_equation_rgb);
+ glGetIntegerv(GL_BLEND_EQUATION_ALPHA, &glstate_backup.blend_equation_alpha);
+ glGetIntegerv(GL_BLEND_SRC_RGB, &glstate_backup.blend_src_rgb);
+ glGetIntegerv(GL_BLEND_DST_RGB, &glstate_backup.blend_dst_rgb);
+ glGetIntegerv(GL_BLEND_SRC_ALPHA, &glstate_backup.blend_src_alpha);
+ glGetIntegerv(GL_BLEND_DST_ALPHA, &glstate_backup.blend_dst_alpha);
+
+ glGetIntegerv(GL_STENCIL_FUNC, &glstate_backup.stencil_front.func);
+ glGetIntegerv(GL_STENCIL_REF, &glstate_backup.stencil_front.ref);
+ glGetIntegerv(GL_STENCIL_VALUE_MASK, &glstate_backup.stencil_front.value_mask);
+ glGetIntegerv(GL_STENCIL_WRITEMASK, &glstate_backup.stencil_front.writemask);
+ glGetIntegerv(GL_STENCIL_FAIL, &glstate_backup.stencil_front.fail);
+ glGetIntegerv(GL_STENCIL_PASS_DEPTH_FAIL, &glstate_backup.stencil_front.pass_depth_fail);
+ glGetIntegerv(GL_STENCIL_PASS_DEPTH_PASS, &glstate_backup.stencil_front.pass_depth_pass);
+
+ glGetIntegerv(GL_STENCIL_BACK_FUNC, &glstate_backup.stencil_back.func);
+ glGetIntegerv(GL_STENCIL_BACK_REF, &glstate_backup.stencil_back.ref);
+ glGetIntegerv(GL_STENCIL_BACK_VALUE_MASK, &glstate_backup.stencil_back.value_mask);
+ glGetIntegerv(GL_STENCIL_BACK_WRITEMASK, &glstate_backup.stencil_back.writemask);
+ glGetIntegerv(GL_STENCIL_BACK_FAIL, &glstate_backup.stencil_back.fail);
+ glGetIntegerv(GL_STENCIL_BACK_PASS_DEPTH_FAIL, &glstate_backup.stencil_back.pass_depth_fail);
+ glGetIntegerv(GL_STENCIL_BACK_PASS_DEPTH_PASS, &glstate_backup.stencil_back.pass_depth_pass);
+
+ // Setup expected GL state.
+ glViewport(0, 0, viewport_width, viewport_height);
+
+ glClearStencil(0);
+ glClearColor(0, 0, 0, 0);
+
+ glActiveTexture(GL_TEXTURE0);
+
+ glDisable(GL_SCISSOR_TEST);
+ glDisable(GL_CULL_FACE);
+
+ // Set blending function for premultiplied alpha.
+ glEnable(GL_BLEND);
+ glBlendEquation(GL_FUNC_ADD);
+ glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
+
+#if !defined(UNBOX_RMLUI_GLES) && !defined(RMLUI_PLATFORM_EMSCRIPTEN) && !defined(__ANDROID__)
+ // We do blending in nonlinear sRGB space because that is the common practice and gives results that we are used to.
+ glDisable(GL_FRAMEBUFFER_SRGB);
+#endif
+
+ glEnable(GL_STENCIL_TEST);
+ glStencilFunc(GL_ALWAYS, 1, GLuint(-1));
+ glStencilMask(GLuint(-1));
+ glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
+
+ glDisable(GL_DEPTH_TEST);
+
+ SetTransform(nullptr);
+
+ render_layers.BeginFrame(viewport_width, viewport_height);
+ glBindFramebuffer(GL_FRAMEBUFFER, render_layers.GetTopLayer().framebuffer);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ UseProgram(ProgramId::None);
+ program_transform_dirty.set();
+ scissor_state = Rml::Rectanglei::MakeInvalid();
+
+ Gfx::CheckGLError("BeginFrame");
+}
+
+void RenderInterface_GL3::EndFrame()
+{
+ const Gfx::FramebufferData& fb_active = render_layers.GetTopLayer();
+ const Gfx::FramebufferData& fb_postprocess = render_layers.GetPostprocessPrimary();
+
+ // Resolve MSAA to postprocess framebuffer.
+ glBindFramebuffer(GL_READ_FRAMEBUFFER, fb_active.framebuffer);
+ glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fb_postprocess.framebuffer);
+
+ glBlitFramebuffer(0, 0, fb_active.width, fb_active.height, 0, 0, fb_postprocess.width, fb_postprocess.height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
+
+ // Draw to the output framebuffer (unbox: default 0 = backbuffer, but the
+ // spike bridge redirects this to its own offscreen FBO).
+ glBindFramebuffer(GL_FRAMEBUFFER, output_framebuffer);
+ glViewport(viewport_offset_x, viewport_offset_y, viewport_width, viewport_height);
+
+ // Assuming we have an opaque background, we can just write to it with the premultiplied alpha blend mode and we'll get the correct result.
+ // Instead, if we had a transparent destination that didn't use premultiplied alpha, we would need to perform a manual un-premultiplication step.
+ glActiveTexture(GL_TEXTURE0);
+ Gfx::BindTexture(fb_postprocess);
+ UseProgram(ProgramId::Passthrough);
+ if (output_flip_y)
+ // unbox: the spike's offscreen FBO is read out / scanned out with row
+ // 0 = top (wlr_buffer convention), but GL's framebuffer origin is
+ // bottom-left. Flip V on the final composite so the submitted buffer
+ // is upright. Display and document/input coords then match 1:1.
+ DrawFullscreenQuad(Rml::Vector2f(0.f, 1.f), Rml::Vector2f(1.f, -1.f));
+ else
+ DrawFullscreenQuad();
+
+ render_layers.EndFrame();
+
+ // Restore GL state.
+ if (glstate_backup.enable_cull_face)
+ glEnable(GL_CULL_FACE);
+ else
+ glDisable(GL_CULL_FACE);
+
+ if (glstate_backup.enable_blend)
+ glEnable(GL_BLEND);
+ else
+ glDisable(GL_BLEND);
+
+ if (glstate_backup.enable_stencil_test)
+ glEnable(GL_STENCIL_TEST);
+ else
+ glDisable(GL_STENCIL_TEST);
+
+ if (glstate_backup.enable_scissor_test)
+ glEnable(GL_SCISSOR_TEST);
+ else
+ glDisable(GL_SCISSOR_TEST);
+
+ if (glstate_backup.enable_depth_test)
+ glEnable(GL_DEPTH_TEST);
+ else
+ glDisable(GL_DEPTH_TEST);
+
+ glViewport(glstate_backup.viewport[0], glstate_backup.viewport[1], glstate_backup.viewport[2], glstate_backup.viewport[3]);
+ glScissor(glstate_backup.scissor[0], glstate_backup.scissor[1], glstate_backup.scissor[2], glstate_backup.scissor[3]);
+
+ glActiveTexture(glstate_backup.active_texture);
+
+ glClearStencil(glstate_backup.stencil_clear_value);
+ glClearColor(glstate_backup.color_clear_value[0], glstate_backup.color_clear_value[1], glstate_backup.color_clear_value[2],
+ glstate_backup.color_clear_value[3]);
+ glColorMask(glstate_backup.color_writemask[0], glstate_backup.color_writemask[1], glstate_backup.color_writemask[2],
+ glstate_backup.color_writemask[3]);
+
+ glBlendEquationSeparate(glstate_backup.blend_equation_rgb, glstate_backup.blend_equation_alpha);
+ glBlendFuncSeparate(glstate_backup.blend_src_rgb, glstate_backup.blend_dst_rgb, glstate_backup.blend_src_alpha, glstate_backup.blend_dst_alpha);
+
+ glStencilFuncSeparate(GL_FRONT, glstate_backup.stencil_front.func, glstate_backup.stencil_front.ref, glstate_backup.stencil_front.value_mask);
+ glStencilMaskSeparate(GL_FRONT, glstate_backup.stencil_front.writemask);
+ glStencilOpSeparate(GL_FRONT, glstate_backup.stencil_front.fail, glstate_backup.stencil_front.pass_depth_fail,
+ glstate_backup.stencil_front.pass_depth_pass);
+
+ glStencilFuncSeparate(GL_BACK, glstate_backup.stencil_back.func, glstate_backup.stencil_back.ref, glstate_backup.stencil_back.value_mask);
+ glStencilMaskSeparate(GL_BACK, glstate_backup.stencil_back.writemask);
+ glStencilOpSeparate(GL_BACK, glstate_backup.stencil_back.fail, glstate_backup.stencil_back.pass_depth_fail,
+ glstate_backup.stencil_back.pass_depth_pass);
+
+ Gfx::CheckGLError("EndFrame");
+}
+
+void RenderInterface_GL3::Clear()
+{
+ glClearColor(0, 0, 0, 1);
+ glClear(GL_COLOR_BUFFER_BIT);
+}
+
+Rml::CompiledGeometryHandle RenderInterface_GL3::CompileGeometry(Rml::Span<const Rml::Vertex> vertices, Rml::Span<const int> indices)
+{
+ constexpr GLenum draw_usage = GL_STATIC_DRAW;
+
+ GLuint vao = 0;
+ GLuint vbo = 0;
+ GLuint ibo = 0;
+
+ glGenVertexArrays(1, &vao);
+ glGenBuffers(1, &vbo);
+ glGenBuffers(1, &ibo);
+ glBindVertexArray(vao);
+
+ glBindBuffer(GL_ARRAY_BUFFER, vbo);
+ glBufferData(GL_ARRAY_BUFFER, sizeof(Rml::Vertex) * vertices.size(), (const void*)vertices.data(), draw_usage);
+
+ glEnableVertexAttribArray((GLuint)Gfx::VertexAttribute::Position);
+ glVertexAttribPointer((GLuint)Gfx::VertexAttribute::Position, 2, GL_FLOAT, GL_FALSE, sizeof(Rml::Vertex),
+ (const GLvoid*)(offsetof(Rml::Vertex, position)));
+
+ glEnableVertexAttribArray((GLuint)Gfx::VertexAttribute::Color0);
+ glVertexAttribPointer((GLuint)Gfx::VertexAttribute::Color0, 4, GL_UNSIGNED_BYTE, GL_TRUE, sizeof(Rml::Vertex),
+ (const GLvoid*)(offsetof(Rml::Vertex, colour)));
+
+ glEnableVertexAttribArray((GLuint)Gfx::VertexAttribute::TexCoord0);
+ glVertexAttribPointer((GLuint)Gfx::VertexAttribute::TexCoord0, 2, GL_FLOAT, GL_FALSE, sizeof(Rml::Vertex),
+ (const GLvoid*)(offsetof(Rml::Vertex, tex_coord)));
+
+ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
+ glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(int) * indices.size(), (const void*)indices.data(), draw_usage);
+
+ glBindVertexArray(0);
+ glBindBuffer(GL_ARRAY_BUFFER, 0);
+
+ Gfx::CheckGLError("CompileGeometry");
+
+ Gfx::CompiledGeometryData* geometry = new Gfx::CompiledGeometryData;
+ geometry->vao = vao;
+ geometry->vbo = vbo;
+ geometry->ibo = ibo;
+ geometry->draw_count = (GLsizei)indices.size();
+
+ return (Rml::CompiledGeometryHandle)geometry;
+}
+
+void RenderInterface_GL3::RenderGeometry(Rml::CompiledGeometryHandle handle, Rml::Vector2f translation, Rml::TextureHandle texture)
+{
+ Gfx::CompiledGeometryData* geometry = (Gfx::CompiledGeometryData*)handle;
+
+ if (texture == TexturePostprocess)
+ {
+ // Do nothing.
+ }
+ else if (texture)
+ {
+ UseProgram(ProgramId::Texture);
+ SubmitTransformUniform(translation);
+ if (texture != TextureEnableWithoutBinding)
+ glBindTexture(GL_TEXTURE_2D, (GLuint)texture);
+ }
+ else
+ {
+ UseProgram(ProgramId::Color);
+ glBindTexture(GL_TEXTURE_2D, 0);
+ SubmitTransformUniform(translation);
+ }
+
+ glBindVertexArray(geometry->vao);
+ glDrawElements(GL_TRIANGLES, geometry->draw_count, GL_UNSIGNED_INT, (const GLvoid*)0);
+
+ glBindVertexArray(0);
+ glBindTexture(GL_TEXTURE_2D, 0);
+
+ Gfx::CheckGLError("RenderCompiledGeometry");
+}
+
+void RenderInterface_GL3::ReleaseGeometry(Rml::CompiledGeometryHandle handle)
+{
+ Gfx::CompiledGeometryData* geometry = (Gfx::CompiledGeometryData*)handle;
+
+ glDeleteVertexArrays(1, &geometry->vao);
+ glDeleteBuffers(1, &geometry->vbo);
+ glDeleteBuffers(1, &geometry->ibo);
+
+ delete geometry;
+}
+
+/// Flip the vertical axis of the rectangle, and move its origin to the vertically opposite side of the viewport.
+/// @note Changes the coordinate system from RmlUi to OpenGL, or equivalently in reverse.
+/// @note The Rectangle::Top and Rectangle::Bottom members will have reverse meaning in the returned rectangle.
+static Rml::Rectanglei VerticallyFlipped(Rml::Rectanglei rect, int viewport_height)
+{
+ RMLUI_ASSERT(rect.Valid());
+ Rml::Rectanglei flipped_rect = rect;
+ flipped_rect.p0.y = viewport_height - rect.p1.y;
+ flipped_rect.p1.y = viewport_height - rect.p0.y;
+ return flipped_rect;
+}
+
+void RenderInterface_GL3::SetScissor(Rml::Rectanglei region, bool vertically_flip)
+{
+ if (region.Valid() != scissor_state.Valid())
+ {
+ if (region.Valid())
+ glEnable(GL_SCISSOR_TEST);
+ else
+ glDisable(GL_SCISSOR_TEST);
+ }
+
+ if (region.Valid() && vertically_flip)
+ region = VerticallyFlipped(region, viewport_height);
+
+ if (region.Valid() && region != scissor_state)
+ {
+ // Some render APIs don't like offscreen positions (WebGL in particular), so clamp them to the viewport.
+ const int x = Rml::Math::Clamp(region.Left(), 0, viewport_width);
+ const int y = Rml::Math::Clamp(viewport_height - region.Bottom(), 0, viewport_height);
+
+ glScissor(x, y, region.Width(), region.Height());
+ }
+
+ Gfx::CheckGLError("SetScissorRegion");
+ scissor_state = region;
+}
+
+void RenderInterface_GL3::EnableScissorRegion(bool enable)
+{
+ // Assume enable is immediately followed by a SetScissorRegion() call, and ignore it here.
+ if (!enable)
+ SetScissor(Rml::Rectanglei::MakeInvalid(), false);
+}
+
+void RenderInterface_GL3::SetScissorRegion(Rml::Rectanglei region)
+{
+ SetScissor(region);
+}
+
+void RenderInterface_GL3::EnableClipMask(bool enable)
+{
+ if (enable)
+ glEnable(GL_STENCIL_TEST);
+ else
+ glDisable(GL_STENCIL_TEST);
+}
+
+void RenderInterface_GL3::RenderToClipMask(Rml::ClipMaskOperation operation, Rml::CompiledGeometryHandle geometry, Rml::Vector2f translation)
+{
+ RMLUI_ASSERT(glIsEnabled(GL_STENCIL_TEST));
+ using Rml::ClipMaskOperation;
+
+ GLint stencil_write_value = 1;
+ GLint stencil_test_value = 1;
+ switch (operation)
+ {
+ case ClipMaskOperation::Set:
+ {
+ // @performance Increment the reference value instead of clearing each time.
+ glClear(GL_STENCIL_BUFFER_BIT);
+ glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
+ }
+ break;
+ case ClipMaskOperation::SetInverse:
+ {
+ glClearStencil(1);
+ glClear(GL_STENCIL_BUFFER_BIT);
+ glClearStencil(0);
+ glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
+ stencil_write_value = 0;
+ }
+ break;
+ case ClipMaskOperation::Intersect:
+ {
+ glStencilOp(GL_KEEP, GL_KEEP, GL_INCR);
+ glGetIntegerv(GL_STENCIL_REF, &stencil_test_value);
+ stencil_test_value += 1;
+ }
+ break;
+ }
+
+ glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);
+ glStencilFunc(GL_ALWAYS, stencil_write_value, GLuint(-1));
+
+ RenderGeometry(geometry, translation, {});
+
+ // Restore state
+ // @performance Cache state so we don't toggle it unnecessarily.
+ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
+ glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
+ glStencilFunc(GL_EQUAL, stencil_test_value, GLuint(-1));
+}
+
+// Set to byte packing, or the compiler will expand our struct, which means it won't read correctly from file
+#pragma pack(1)
+struct TGAHeader {
+ char idLength;
+ char colourMapType;
+ char dataType;
+ short int colourMapOrigin;
+ short int colourMapLength;
+ char colourMapDepth;
+ short int xOrigin;
+ short int yOrigin;
+ short int width;
+ short int height;
+ char bitsPerPixel;
+ char imageDescriptor;
+};
+// Restore packing
+#pragma pack()
+
+Rml::TextureHandle RenderInterface_GL3::LoadTexture(Rml::Vector2i& texture_dimensions, const Rml::String& source)
+{
+ Rml::FileInterface* file_interface = Rml::GetFileInterface();
+ Rml::FileHandle file_handle = file_interface->Open(source);
+ if (!file_handle)
+ {
+ return false;
+ }
+
+ file_interface->Seek(file_handle, 0, SEEK_END);
+ size_t buffer_size = file_interface->Tell(file_handle);
+ file_interface->Seek(file_handle, 0, SEEK_SET);
+
+ if (buffer_size <= sizeof(TGAHeader))
+ {
+ Rml::Log::Message(Rml::Log::LT_ERROR, "Texture file size is smaller than TGAHeader, file is not a valid TGA image.");
+ file_interface->Close(file_handle);
+ return false;
+ }
+
+ using Rml::byte;
+ Rml::UniquePtr<byte[]> buffer(new byte[buffer_size]);
+ file_interface->Read(buffer.get(), buffer_size, file_handle);
+ file_interface->Close(file_handle);
+
+ TGAHeader header;
+ memcpy(&header, buffer.get(), sizeof(TGAHeader));
+
+ int color_mode = header.bitsPerPixel / 8;
+ const size_t image_size = header.width * header.height * 4; // We always make 32bit textures
+
+ if (header.dataType != 2)
+ {
+ Rml::Log::Message(Rml::Log::LT_ERROR, "Only 24/32bit uncompressed TGAs are supported.");
+ return false;
+ }
+
+ // Ensure we have at least 3 colors
+ if (color_mode < 3)
+ {
+ Rml::Log::Message(Rml::Log::LT_ERROR, "Only 24 and 32bit textures are supported.");
+ return false;
+ }
+
+ const byte* image_src = buffer.get() + sizeof(TGAHeader);
+ Rml::UniquePtr<byte[]> image_dest_buffer(new byte[image_size]);
+ byte* image_dest = image_dest_buffer.get();
+ const bool top_to_bottom_order = ((header.imageDescriptor & 32) != 0);
+
+ // Targa is BGR, swap to RGB, flip Y axis as necessary, and convert to premultiplied alpha.
+ for (long y = 0; y < header.height; y++)
+ {
+ long read_index = y * header.width * color_mode;
+ long write_index = top_to_bottom_order ? (y * header.width * 4) : (header.height - y - 1) * header.width * 4;
+ for (long x = 0; x < header.width; x++)
+ {
+ image_dest[write_index] = image_src[read_index + 2];
+ image_dest[write_index + 1] = image_src[read_index + 1];
+ image_dest[write_index + 2] = image_src[read_index];
+ if (color_mode == 4)
+ {
+ const byte alpha = image_src[read_index + 3];
+ for (size_t j = 0; j < 3; j++)
+ image_dest[write_index + j] = byte((image_dest[write_index + j] * alpha) / 255);
+ image_dest[write_index + 3] = alpha;
+ }
+ else
+ image_dest[write_index + 3] = 255;
+
+ write_index += 4;
+ read_index += color_mode;
+ }
+ }
+
+ texture_dimensions.x = header.width;
+ texture_dimensions.y = header.height;
+
+ return GenerateTexture({image_dest, image_size}, texture_dimensions);
+}
+
+Rml::TextureHandle RenderInterface_GL3::GenerateTexture(Rml::Span<const Rml::byte> source_data, Rml::Vector2i source_dimensions)
+{
+ RMLUI_ASSERT(source_data.data() && source_data.size() == size_t(source_dimensions.x * source_dimensions.y * 4));
+
+ GLuint texture_id = Gfx::CreateTexture(source_data, source_dimensions);
+ if (texture_id == 0)
+ {
+ Rml::Log::Message(Rml::Log::LT_ERROR, "Failed to generate texture.");
+ return {};
+ }
+ return (Rml::TextureHandle)texture_id;
+}
+
+void RenderInterface_GL3::DrawFullscreenQuad()
+{
+ RenderGeometry(fullscreen_quad_geometry, {}, RenderInterface_GL3::TexturePostprocess);
+}
+
+void RenderInterface_GL3::DrawFullscreenQuad(Rml::Vector2f uv_offset, Rml::Vector2f uv_scaling)
+{
+ Rml::Mesh mesh;
+ Rml::MeshUtilities::GenerateQuad(mesh, Rml::Vector2f(-1), Rml::Vector2f(2), {});
+ if (uv_offset != Rml::Vector2f() || uv_scaling != Rml::Vector2f(1.f))
+ {
+ for (Rml::Vertex& vertex : mesh.vertices)
+ vertex.tex_coord = (vertex.tex_coord * uv_scaling) + uv_offset;
+ }
+ const Rml::CompiledGeometryHandle geometry = CompileGeometry(mesh.vertices, mesh.indices);
+ RenderGeometry(geometry, {}, RenderInterface_GL3::TexturePostprocess);
+ ReleaseGeometry(geometry);
+}
+
+static Rml::Colourf ConvertToColorf(Rml::ColourbPremultiplied c0)
+{
+ Rml::Colourf result;
+ for (int i = 0; i < 4; i++)
+ result[i] = (1.f / 255.f) * float(c0[i]);
+ return result;
+}
+
+static void SigmaToParameters(const float desired_sigma, int& out_pass_level, float& out_sigma)
+{
+ constexpr int max_num_passes = 10;
+ static_assert(max_num_passes < 31, "");
+ constexpr float max_single_pass_sigma = 3.0f;
+ out_pass_level = Rml::Math::Clamp(Rml::Math::Log2(int(desired_sigma * (2.f / max_single_pass_sigma))), 0, max_num_passes);
+ out_sigma = Rml::Math::Clamp(desired_sigma / float(1 << out_pass_level), 0.0f, max_single_pass_sigma);
+}
+
+static void SetTexCoordLimits(GLint tex_coord_min_location, GLint tex_coord_max_location, Rml::Rectanglei rectangle_flipped,
+ Rml::Vector2i framebuffer_size)
+{
+ // Offset by half-texel values so that texture lookups are clamped to fragment centers, thereby avoiding color
+ // bleeding from neighboring texels due to bilinear interpolation.
+ const Rml::Vector2f min = (Rml::Vector2f(rectangle_flipped.p0) + Rml::Vector2f(0.5f)) / Rml::Vector2f(framebuffer_size);
+ const Rml::Vector2f max = (Rml::Vector2f(rectangle_flipped.p1) - Rml::Vector2f(0.5f)) / Rml::Vector2f(framebuffer_size);
+
+ glUniform2f(tex_coord_min_location, min.x, min.y);
+ glUniform2f(tex_coord_max_location, max.x, max.y);
+}
+
+static void SetBlurWeights(GLint weights_location, float sigma)
+{
+ constexpr int num_weights = BLUR_NUM_WEIGHTS;
+ float weights[num_weights];
+ float normalization = 0.0f;
+ for (int i = 0; i < num_weights; i++)
+ {
+ if (Rml::Math::Absolute(sigma) < 0.1f)
+ weights[i] = float(i == 0);
+ else
+ weights[i] = Rml::Math::Exp(-float(i * i) / (2.0f * sigma * sigma)) / (Rml::Math::SquareRoot(2.f * Rml::Math::RMLUI_PI) * sigma);
+
+ normalization += (i == 0 ? 1.f : 2.0f) * weights[i];
+ }
+ for (int i = 0; i < num_weights; i++)
+ weights[i] /= normalization;
+
+ glUniform1fv(weights_location, (GLsizei)num_weights, &weights[0]);
+}
+
+void RenderInterface_GL3::RenderBlur(float sigma, const Gfx::FramebufferData& source_destination, const Gfx::FramebufferData& temp,
+ const Rml::Rectanglei window_flipped)
+{
+ RMLUI_ASSERT(&source_destination != &temp && source_destination.width == temp.width && source_destination.height == temp.height);
+ RMLUI_ASSERT(window_flipped.Valid());
+
+ int pass_level = 0;
+ SigmaToParameters(sigma, pass_level, sigma);
+
+ const Rml::Rectanglei original_scissor = scissor_state;
+
+ // Begin by downscaling so that the blur pass can be done at a reduced resolution for large sigma.
+ Rml::Rectanglei scissor = window_flipped;
+
+ UseProgram(ProgramId::Passthrough);
+ SetScissor(scissor, true);
+
+ // Downscale by iterative half-scaling with bilinear filtering, to reduce aliasing.
+ glViewport(0, 0, source_destination.width / 2, source_destination.height / 2);
+
+ // Scale UVs if we have even dimensions, such that texture fetches align perfectly between texels, thereby producing a 50% blend of
+ // neighboring texels.
+ const Rml::Vector2f uv_scaling = {(source_destination.width % 2 == 1) ? (1.f - 1.f / float(source_destination.width)) : 1.f,
+ (source_destination.height % 2 == 1) ? (1.f - 1.f / float(source_destination.height)) : 1.f};
+
+ for (int i = 0; i < pass_level; i++)
+ {
+ scissor.p0 = (scissor.p0 + Rml::Vector2i(1)) / 2;
+ scissor.p1 = Rml::Math::Max(scissor.p1 / 2, scissor.p0);
+ const bool from_source = (i % 2 == 0);
+ Gfx::BindTexture(from_source ? source_destination : temp);
+ glBindFramebuffer(GL_FRAMEBUFFER, (from_source ? temp : source_destination).framebuffer);
+ SetScissor(scissor, true);
+
+ DrawFullscreenQuad({}, uv_scaling);
+ }
+
+ glViewport(0, 0, source_destination.width, source_destination.height);
+
+ // Ensure texture data end up in the temp buffer. Depending on the last downscaling, we might need to move it from the source_destination buffer.
+ const bool transfer_to_temp_buffer = (pass_level % 2 == 0);
+ if (transfer_to_temp_buffer)
+ {
+ Gfx::BindTexture(source_destination);
+ glBindFramebuffer(GL_FRAMEBUFFER, temp.framebuffer);
+ DrawFullscreenQuad();
+ }
+
+ // Set up uniforms.
+ UseProgram(ProgramId::Blur);
+ SetBlurWeights(GetUniformLocation(UniformId::Weights), sigma);
+ SetTexCoordLimits(GetUniformLocation(UniformId::TexCoordMin), GetUniformLocation(UniformId::TexCoordMax), scissor,
+ {source_destination.width, source_destination.height});
+
+ const GLint texel_offset_location = GetUniformLocation(UniformId::TexelOffset);
+ auto SetTexelOffset = [texel_offset_location](Rml::Vector2f blur_direction, int texture_dimension) {
+ const Rml::Vector2f texel_offset = blur_direction * (1.0f / float(texture_dimension));
+ glUniform2f(texel_offset_location, texel_offset.x, texel_offset.y);
+ };
+
+ // Blur render pass - vertical.
+ Gfx::BindTexture(temp);
+ glBindFramebuffer(GL_FRAMEBUFFER, source_destination.framebuffer);
+
+ SetTexelOffset({0.f, 1.f}, temp.height);
+ DrawFullscreenQuad();
+
+ // Blur render pass - horizontal.
+ Gfx::BindTexture(source_destination);
+ glBindFramebuffer(GL_FRAMEBUFFER, temp.framebuffer);
+
+ // Add a 1px transparent border around the blur region by first clearing with a padded scissor. This helps prevent
+ // artifacts when upscaling the blur result in the later step. On Intel and AMD, we have observed that during
+ // blitting with linear filtering, pixels outside the 'src' region can be blended into the output. On the other
+ // hand, it looks like Nvidia clamps the pixels to the source edge, which is what we really want. Regardless, we
+ // work around the issue with this extra step.
+ SetScissor(scissor.Extend(1), true);
+ glClear(GL_COLOR_BUFFER_BIT);
+ SetScissor(scissor, true);
+
+ SetTexelOffset({1.f, 0.f}, source_destination.width);
+ DrawFullscreenQuad();
+
+ // Blit the blurred image to the scissor region with upscaling.
+ SetScissor(window_flipped, true);
+ glBindFramebuffer(GL_READ_FRAMEBUFFER, temp.framebuffer);
+ glBindFramebuffer(GL_DRAW_FRAMEBUFFER, source_destination.framebuffer);
+
+ const Rml::Vector2i src_min = scissor.p0;
+ const Rml::Vector2i src_max = scissor.p1;
+ const Rml::Vector2i dst_min = window_flipped.p0;
+ const Rml::Vector2i dst_max = window_flipped.p1;
+ glBlitFramebuffer(src_min.x, src_min.y, src_max.x, src_max.y, dst_min.x, dst_min.y, dst_max.x, dst_max.y, GL_COLOR_BUFFER_BIT, GL_LINEAR);
+
+ // The above upscale blit might be jittery at low resolutions (large pass levels). This is especially noticeable when moving an element with
+ // backdrop blur around or when trying to click/hover an element within a blurred region since it may be rendered at an offset. For more stable
+ // and accurate rendering we next upscale the blur image by an exact power-of-two. However, this may not fill the edges completely so we need to
+ // do the above first. Note that this strategy may sometimes result in visible seams. Alternatively, we could try to enlarge the window to the
+ // next power-of-two size and then downsample and blur that.
+ const Rml::Vector2i target_min = src_min * (1 << pass_level);
+ const Rml::Vector2i target_max = src_max * (1 << pass_level);
+ if (target_min != dst_min || target_max != dst_max)
+ {
+ glBlitFramebuffer(src_min.x, src_min.y, src_max.x, src_max.y, target_min.x, target_min.y, target_max.x, target_max.y, GL_COLOR_BUFFER_BIT,
+ GL_LINEAR);
+ }
+
+ // Restore render state.
+ SetScissor(original_scissor);
+
+ Gfx::CheckGLError("Blur");
+}
+
+void RenderInterface_GL3::ReleaseTexture(Rml::TextureHandle texture_handle)
+{
+ glDeleteTextures(1, (GLuint*)&texture_handle);
+}
+
+void RenderInterface_GL3::SetTransform(const Rml::Matrix4f* new_transform)
+{
+ transform = (new_transform ? (projection * (*new_transform)) : projection);
+ program_transform_dirty.set();
+}
+
+enum class FilterType { Invalid = 0, Passthrough, Blur, DropShadow, ColorMatrix, MaskImage };
+struct CompiledFilter {
+ FilterType type;
+
+ // Passthrough
+ float blend_factor;
+
+ // Blur
+ float sigma;
+
+ // Drop shadow
+ Rml::Vector2f offset;
+ Rml::ColourbPremultiplied color;
+
+ // ColorMatrix
+ Rml::Matrix4f color_matrix;
+};
+
+Rml::CompiledFilterHandle RenderInterface_GL3::CompileFilter(const Rml::String& name, const Rml::Dictionary& parameters)
+{
+ CompiledFilter filter = {};
+
+ if (name == "opacity")
+ {
+ filter.type = FilterType::Passthrough;
+ filter.blend_factor = Rml::Get(parameters, "value", 1.0f);
+ }
+ else if (name == "blur")
+ {
+ filter.type = FilterType::Blur;
+ filter.sigma = Rml::Get(parameters, "sigma", 1.0f);
+ }
+ else if (name == "drop-shadow")
+ {
+ filter.type = FilterType::DropShadow;
+ filter.sigma = Rml::Get(parameters, "sigma", 0.f);
+ filter.color = Rml::Get(parameters, "color", Rml::Colourb()).ToPremultiplied();
+ filter.offset = Rml::Get(parameters, "offset", Rml::Vector2f(0.f));
+ }
+ else if (name == "brightness")
+ {
+ filter.type = FilterType::ColorMatrix;
+ const float value = Rml::Get(parameters, "value", 1.0f);
+ filter.color_matrix = Rml::Matrix4f::Diag(value, value, value, 1.f);
+ }
+ else if (name == "contrast")
+ {
+ filter.type = FilterType::ColorMatrix;
+ const float value = Rml::Get(parameters, "value", 1.0f);
+ const float grayness = 0.5f - 0.5f * value;
+ filter.color_matrix = Rml::Matrix4f::Diag(value, value, value, 1.f);
+ filter.color_matrix.SetColumn(3, Rml::Vector4f(grayness, grayness, grayness, 1.f));
+ }
+ else if (name == "invert")
+ {
+ filter.type = FilterType::ColorMatrix;
+ const float value = Rml::Math::Clamp(Rml::Get(parameters, "value", 1.0f), 0.f, 1.f);
+ const float inverted = 1.f - 2.f * value;
+ filter.color_matrix = Rml::Matrix4f::Diag(inverted, inverted, inverted, 1.f);
+ filter.color_matrix.SetColumn(3, Rml::Vector4f(value, value, value, 1.f));
+ }
+ else if (name == "grayscale")
+ {
+ filter.type = FilterType::ColorMatrix;
+ const float value = Rml::Get(parameters, "value", 1.0f);
+ const float rev_value = 1.f - value;
+ const Rml::Vector3f gray = value * Rml::Vector3f(0.2126f, 0.7152f, 0.0722f);
+ // clang-format off
+ filter.color_matrix = Rml::Matrix4f::FromRows(
+ {gray.x + rev_value, gray.y, gray.z, 0.f},
+ {gray.x, gray.y + rev_value, gray.z, 0.f},
+ {gray.x, gray.y, gray.z + rev_value, 0.f},
+ {0.f, 0.f, 0.f, 1.f}
+ );
+ // clang-format on
+ }
+ else if (name == "sepia")
+ {
+ filter.type = FilterType::ColorMatrix;
+ const float value = Rml::Get(parameters, "value", 1.0f);
+ const float rev_value = 1.f - value;
+ const Rml::Vector3f r_mix = value * Rml::Vector3f(0.393f, 0.769f, 0.189f);
+ const Rml::Vector3f g_mix = value * Rml::Vector3f(0.349f, 0.686f, 0.168f);
+ const Rml::Vector3f b_mix = value * Rml::Vector3f(0.272f, 0.534f, 0.131f);
+ // clang-format off
+ filter.color_matrix = Rml::Matrix4f::FromRows(
+ {r_mix.x + rev_value, r_mix.y, r_mix.z, 0.f},
+ {g_mix.x, g_mix.y + rev_value, g_mix.z, 0.f},
+ {b_mix.x, b_mix.y, b_mix.z + rev_value, 0.f},
+ {0.f, 0.f, 0.f, 1.f}
+ );
+ // clang-format on
+ }
+ else if (name == "hue-rotate")
+ {
+ // Hue-rotation and saturation values based on: https://www.w3.org/TR/filter-effects-1/#attr-valuedef-type-huerotate
+ filter.type = FilterType::ColorMatrix;
+ const float value = Rml::Get(parameters, "value", 1.0f);
+ const float s = Rml::Math::Sin(value);
+ const float c = Rml::Math::Cos(value);
+ // clang-format off
+ filter.color_matrix = Rml::Matrix4f::FromRows(
+ {0.213f + 0.787f * c - 0.213f * s, 0.715f - 0.715f * c - 0.715f * s, 0.072f - 0.072f * c + 0.928f * s, 0.f},
+ {0.213f - 0.213f * c + 0.143f * s, 0.715f + 0.285f * c + 0.140f * s, 0.072f - 0.072f * c - 0.283f * s, 0.f},
+ {0.213f - 0.213f * c - 0.787f * s, 0.715f - 0.715f * c + 0.715f * s, 0.072f + 0.928f * c + 0.072f * s, 0.f},
+ {0.f, 0.f, 0.f, 1.f}
+ );
+ // clang-format on
+ }
+ else if (name == "saturate")
+ {
+ filter.type = FilterType::ColorMatrix;
+ const float value = Rml::Get(parameters, "value", 1.0f);
+ // clang-format off
+ filter.color_matrix = Rml::Matrix4f::FromRows(
+ {0.213f + 0.787f * value, 0.715f - 0.715f * value, 0.072f - 0.072f * value, 0.f},
+ {0.213f - 0.213f * value, 0.715f + 0.285f * value, 0.072f - 0.072f * value, 0.f},
+ {0.213f - 0.213f * value, 0.715f - 0.715f * value, 0.072f + 0.928f * value, 0.f},
+ {0.f, 0.f, 0.f, 1.f}
+ );
+ // clang-format on
+ }
+
+ if (filter.type != FilterType::Invalid)
+ return reinterpret_cast<Rml::CompiledFilterHandle>(new CompiledFilter(std::move(filter)));
+
+ Rml::Log::Message(Rml::Log::LT_WARNING, "Unsupported filter type '%s'.", name.c_str());
+ return {};
+}
+
+void RenderInterface_GL3::ReleaseFilter(Rml::CompiledFilterHandle filter)
+{
+ delete reinterpret_cast<CompiledFilter*>(filter);
+}
+
+enum class CompiledShaderType { Invalid = 0, Gradient, Creation };
+struct CompiledShader {
+ CompiledShaderType type;
+
+ // Gradient
+ ShaderGradientFunction gradient_function;
+ Rml::Vector2f p;
+ Rml::Vector2f v;
+ Rml::Vector<float> stop_positions;
+ Rml::Vector<Rml::Colourf> stop_colors;
+
+ // Shader
+ Rml::Vector2f dimensions;
+};
+
+Rml::CompiledShaderHandle RenderInterface_GL3::CompileShader(const Rml::String& name, const Rml::Dictionary& parameters)
+{
+ auto ApplyColorStopList = [](CompiledShader& shader, const Rml::Dictionary& shader_parameters) {
+ auto it = shader_parameters.find("color_stop_list");
+ RMLUI_ASSERT(it != shader_parameters.end() && it->second.GetType() == Rml::Variant::COLORSTOPLIST);
+ const Rml::ColorStopList& color_stop_list = it->second.GetReference<Rml::ColorStopList>();
+ const int num_stops = Rml::Math::Min((int)color_stop_list.size(), MAX_NUM_STOPS);
+
+ shader.stop_positions.resize(num_stops);
+ shader.stop_colors.resize(num_stops);
+ for (int i = 0; i < num_stops; i++)
+ {
+ const Rml::ColorStop& stop = color_stop_list[i];
+ RMLUI_ASSERT(stop.position.unit == Rml::Unit::NUMBER);
+ shader.stop_positions[i] = stop.position.number;
+ shader.stop_colors[i] = ConvertToColorf(stop.color);
+ }
+ };
+
+ CompiledShader shader = {};
+
+ if (name == "linear-gradient")
+ {
+ shader.type = CompiledShaderType::Gradient;
+ const bool repeating = Rml::Get(parameters, "repeating", false);
+ shader.gradient_function = (repeating ? ShaderGradientFunction::RepeatingLinear : ShaderGradientFunction::Linear);
+ shader.p = Rml::Get(parameters, "p0", Rml::Vector2f(0.f));
+ shader.v = Rml::Get(parameters, "p1", Rml::Vector2f(0.f)) - shader.p;
+ ApplyColorStopList(shader, parameters);
+ }
+ else if (name == "radial-gradient")
+ {
+ shader.type = CompiledShaderType::Gradient;
+ const bool repeating = Rml::Get(parameters, "repeating", false);
+ shader.gradient_function = (repeating ? ShaderGradientFunction::RepeatingRadial : ShaderGradientFunction::Radial);
+ shader.p = Rml::Get(parameters, "center", Rml::Vector2f(0.f));
+ shader.v = Rml::Vector2f(1.f) / Rml::Get(parameters, "radius", Rml::Vector2f(1.f));
+ ApplyColorStopList(shader, parameters);
+ }
+ else if (name == "conic-gradient")
+ {
+ shader.type = CompiledShaderType::Gradient;
+ const bool repeating = Rml::Get(parameters, "repeating", false);
+ shader.gradient_function = (repeating ? ShaderGradientFunction::RepeatingConic : ShaderGradientFunction::Conic);
+ shader.p = Rml::Get(parameters, "center", Rml::Vector2f(0.f));
+ const float angle = Rml::Get(parameters, "angle", 0.f);
+ shader.v = {Rml::Math::Cos(angle), Rml::Math::Sin(angle)};
+ ApplyColorStopList(shader, parameters);
+ }
+ else if (name == "shader")
+ {
+ const Rml::String value = Rml::Get(parameters, "value", Rml::String());
+ if (value == "creation")
+ {
+ shader.type = CompiledShaderType::Creation;
+ shader.dimensions = Rml::Get(parameters, "dimensions", Rml::Vector2f(0.f));
+ }
+ }
+
+ if (shader.type != CompiledShaderType::Invalid)
+ return reinterpret_cast<Rml::CompiledShaderHandle>(new CompiledShader(std::move(shader)));
+
+ Rml::Log::Message(Rml::Log::LT_WARNING, "Unsupported shader type '%s'.", name.c_str());
+ return {};
+}
+
+void RenderInterface_GL3::RenderShader(Rml::CompiledShaderHandle shader_handle, Rml::CompiledGeometryHandle geometry_handle,
+ Rml::Vector2f translation, Rml::TextureHandle /*texture*/)
+{
+ RMLUI_ASSERT(shader_handle && geometry_handle);
+ const CompiledShader& shader = *reinterpret_cast<CompiledShader*>(shader_handle);
+ const CompiledShaderType type = shader.type;
+ const Gfx::CompiledGeometryData& geometry = *reinterpret_cast<Gfx::CompiledGeometryData*>(geometry_handle);
+
+ switch (type)
+ {
+ case CompiledShaderType::Gradient:
+ {
+ RMLUI_ASSERT(shader.stop_positions.size() == shader.stop_colors.size());
+ const int num_stops = (int)shader.stop_positions.size();
+
+ UseProgram(ProgramId::Gradient);
+ glUniform1i(GetUniformLocation(UniformId::Func), static_cast<int>(shader.gradient_function));
+ glUniform2f(GetUniformLocation(UniformId::P), shader.p.x, shader.p.y);
+ glUniform2f(GetUniformLocation(UniformId::V), shader.v.x, shader.v.y);
+ glUniform1i(GetUniformLocation(UniformId::NumStops), num_stops);
+ glUniform1fv(GetUniformLocation(UniformId::StopPositions), num_stops, shader.stop_positions.data());
+ glUniform4fv(GetUniformLocation(UniformId::StopColors), num_stops, shader.stop_colors[0]);
+
+ SubmitTransformUniform(translation);
+ glBindVertexArray(geometry.vao);
+ glDrawElements(GL_TRIANGLES, geometry.draw_count, GL_UNSIGNED_INT, (const GLvoid*)0);
+ glBindVertexArray(0);
+ }
+ break;
+ case CompiledShaderType::Creation:
+ {
+ const double time = Rml::GetSystemInterface()->GetElapsedTime();
+
+ UseProgram(ProgramId::Creation);
+ glUniform1f(GetUniformLocation(UniformId::Value), (float)time);
+ glUniform2f(GetUniformLocation(UniformId::Dimensions), shader.dimensions.x, shader.dimensions.y);
+
+ SubmitTransformUniform(translation);
+ glBindVertexArray(geometry.vao);
+ glDrawElements(GL_TRIANGLES, geometry.draw_count, GL_UNSIGNED_INT, (const GLvoid*)0);
+ glBindVertexArray(0);
+ }
+ break;
+ case CompiledShaderType::Invalid:
+ {
+ Rml::Log::Message(Rml::Log::LT_WARNING, "Unhandled render shader %d.", (int)type);
+ }
+ break;
+ }
+
+ Gfx::CheckGLError("RenderShader");
+}
+
+void RenderInterface_GL3::ReleaseShader(Rml::CompiledShaderHandle shader_handle)
+{
+ delete reinterpret_cast<CompiledShader*>(shader_handle);
+}
+
+void RenderInterface_GL3::BlitLayerToPostprocessPrimary(Rml::LayerHandle layer_handle)
+{
+ const Gfx::FramebufferData& source = render_layers.GetLayer(layer_handle);
+ const Gfx::FramebufferData& destination = render_layers.GetPostprocessPrimary();
+ glBindFramebuffer(GL_READ_FRAMEBUFFER, source.framebuffer);
+ glBindFramebuffer(GL_DRAW_FRAMEBUFFER, destination.framebuffer);
+
+ // Blit and resolve MSAA. Any active scissor state will restrict the size of the blit region.
+ glBlitFramebuffer(0, 0, source.width, source.height, 0, 0, destination.width, destination.height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
+}
+
+void RenderInterface_GL3::RenderFilters(Rml::Span<const Rml::CompiledFilterHandle> filter_handles)
+{
+ for (const Rml::CompiledFilterHandle filter_handle : filter_handles)
+ {
+ const CompiledFilter& filter = *reinterpret_cast<const CompiledFilter*>(filter_handle);
+ const FilterType type = filter.type;
+
+ switch (type)
+ {
+ case FilterType::Passthrough:
+ {
+ UseProgram(ProgramId::Passthrough);
+ glBlendFunc(GL_CONSTANT_COLOR, GL_ZERO);
+ glBlendColor(filter.blend_factor, filter.blend_factor, filter.blend_factor, filter.blend_factor);
+
+ const Gfx::FramebufferData& source = render_layers.GetPostprocessPrimary();
+ const Gfx::FramebufferData& destination = render_layers.GetPostprocessSecondary();
+ Gfx::BindTexture(source);
+ glBindFramebuffer(GL_FRAMEBUFFER, destination.framebuffer);
+
+ DrawFullscreenQuad();
+
+ render_layers.SwapPostprocessPrimarySecondary();
+ glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
+ }
+ break;
+ case FilterType::Blur:
+ {
+ glDisable(GL_BLEND);
+
+ const Gfx::FramebufferData& source_destination = render_layers.GetPostprocessPrimary();
+ const Gfx::FramebufferData& temp = render_layers.GetPostprocessSecondary();
+
+ const Rml::Rectanglei window_flipped = VerticallyFlipped(scissor_state, viewport_height);
+ RenderBlur(filter.sigma, source_destination, temp, window_flipped);
+
+ glEnable(GL_BLEND);
+ }
+ break;
+ case FilterType::DropShadow:
+ {
+ UseProgram(ProgramId::DropShadow);
+ glDisable(GL_BLEND);
+
+ Rml::Colourf color = ConvertToColorf(filter.color);
+ glUniform4fv(GetUniformLocation(UniformId::Color), 1, &color[0]);
+
+ const Gfx::FramebufferData& primary = render_layers.GetPostprocessPrimary();
+ const Gfx::FramebufferData& secondary = render_layers.GetPostprocessSecondary();
+ Gfx::BindTexture(primary);
+ glBindFramebuffer(GL_FRAMEBUFFER, secondary.framebuffer);
+
+ const Rml::Rectanglei window_flipped = VerticallyFlipped(scissor_state, viewport_height);
+ SetTexCoordLimits(GetUniformLocation(UniformId::TexCoordMin), GetUniformLocation(UniformId::TexCoordMax), window_flipped,
+ {primary.width, primary.height});
+
+ const Rml::Vector2f uv_offset = filter.offset / Rml::Vector2f(-(float)viewport_width, (float)viewport_height);
+ DrawFullscreenQuad(uv_offset);
+
+ if (filter.sigma >= 0.5f)
+ {
+ const Gfx::FramebufferData& tertiary = render_layers.GetPostprocessTertiary();
+ RenderBlur(filter.sigma, secondary, tertiary, window_flipped);
+ }
+
+ UseProgram(ProgramId::Passthrough);
+ BindTexture(primary);
+ glEnable(GL_BLEND);
+ DrawFullscreenQuad();
+
+ render_layers.SwapPostprocessPrimarySecondary();
+ }
+ break;
+ case FilterType::ColorMatrix:
+ {
+ UseProgram(ProgramId::ColorMatrix);
+ glDisable(GL_BLEND);
+
+ const GLint uniform_location = program_data->uniforms.Get(ProgramId::ColorMatrix, UniformId::ColorMatrix);
+ constexpr bool transpose = std::is_same<decltype(filter.color_matrix), Rml::RowMajorMatrix4f>::value;
+ glUniformMatrix4fv(uniform_location, 1, transpose, filter.color_matrix.data());
+
+ const Gfx::FramebufferData& source = render_layers.GetPostprocessPrimary();
+ const Gfx::FramebufferData& destination = render_layers.GetPostprocessSecondary();
+ Gfx::BindTexture(source);
+ glBindFramebuffer(GL_FRAMEBUFFER, destination.framebuffer);
+
+ DrawFullscreenQuad();
+
+ render_layers.SwapPostprocessPrimarySecondary();
+ glEnable(GL_BLEND);
+ }
+ break;
+ case FilterType::MaskImage:
+ {
+ UseProgram(ProgramId::BlendMask);
+ glDisable(GL_BLEND);
+
+ const Gfx::FramebufferData& source = render_layers.GetPostprocessPrimary();
+ const Gfx::FramebufferData& blend_mask = render_layers.GetBlendMask();
+ const Gfx::FramebufferData& destination = render_layers.GetPostprocessSecondary();
+
+ Gfx::BindTexture(source);
+ glActiveTexture(GL_TEXTURE1);
+ Gfx::BindTexture(blend_mask);
+ glActiveTexture(GL_TEXTURE0);
+
+ glBindFramebuffer(GL_FRAMEBUFFER, destination.framebuffer);
+
+ DrawFullscreenQuad();
+
+ render_layers.SwapPostprocessPrimarySecondary();
+ glEnable(GL_BLEND);
+ }
+ break;
+ case FilterType::Invalid:
+ {
+ Rml::Log::Message(Rml::Log::LT_WARNING, "Unhandled render filter %d.", (int)type);
+ }
+ break;
+ }
+ }
+
+ Gfx::CheckGLError("RenderFilter");
+}
+
+Rml::LayerHandle RenderInterface_GL3::PushLayer()
+{
+ const Rml::LayerHandle layer_handle = render_layers.PushLayer();
+
+ glBindFramebuffer(GL_FRAMEBUFFER, render_layers.GetLayer(layer_handle).framebuffer);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ return layer_handle;
+}
+
+void RenderInterface_GL3::CompositeLayers(Rml::LayerHandle source_handle, Rml::LayerHandle destination_handle, Rml::BlendMode blend_mode,
+ Rml::Span<const Rml::CompiledFilterHandle> filters)
+{
+ using Rml::BlendMode;
+
+ // Blit source layer to postprocessing buffer. Do this regardless of whether we actually have any filters to be
+ // applied, because we need to resolve the multi-sampled framebuffer in any case.
+ // @performance If we have BlendMode::Replace and no filters or mask then we can just blit directly to the destination.
+ BlitLayerToPostprocessPrimary(source_handle);
+
+ // Render the filters, the PostprocessPrimary framebuffer is used for both input and output.
+ RenderFilters(filters);
+
+ // Render to the destination layer.
+ glBindFramebuffer(GL_FRAMEBUFFER, render_layers.GetLayer(destination_handle).framebuffer);
+ Gfx::BindTexture(render_layers.GetPostprocessPrimary());
+
+ UseProgram(ProgramId::Passthrough);
+
+ if (blend_mode == BlendMode::Replace)
+ glDisable(GL_BLEND);
+
+ DrawFullscreenQuad();
+
+ if (blend_mode == BlendMode::Replace)
+ glEnable(GL_BLEND);
+
+ if (destination_handle != render_layers.GetTopLayerHandle())
+ glBindFramebuffer(GL_FRAMEBUFFER, render_layers.GetTopLayer().framebuffer);
+
+ Gfx::CheckGLError("CompositeLayers");
+}
+
+void RenderInterface_GL3::PopLayer()
+{
+ render_layers.PopLayer();
+ glBindFramebuffer(GL_FRAMEBUFFER, render_layers.GetTopLayer().framebuffer);
+}
+
+Rml::TextureHandle RenderInterface_GL3::SaveLayerAsTexture()
+{
+ RMLUI_ASSERT(scissor_state.Valid());
+ const Rml::Rectanglei bounds = scissor_state;
+
+ GLuint render_texture = Gfx::CreateTexture({}, bounds.Size());
+ if (render_texture == 0)
+ {
+ Rml::Log::Message(Rml::Log::LT_ERROR, "Failed to create render texture.");
+ return {};
+ }
+
+ BlitLayerToPostprocessPrimary(render_layers.GetTopLayerHandle());
+
+ EnableScissorRegion(false);
+
+ const Gfx::FramebufferData& source = render_layers.GetPostprocessPrimary();
+ const Gfx::FramebufferData& destination = render_layers.GetPostprocessSecondary();
+ glBindFramebuffer(GL_READ_FRAMEBUFFER, source.framebuffer);
+ glBindFramebuffer(GL_DRAW_FRAMEBUFFER, destination.framebuffer);
+
+ // Flip the image vertically, as that convention is used for textures, and move to origin.
+ glBlitFramebuffer( //
+ bounds.Left(), source.height - bounds.Bottom(), // src0
+ bounds.Right(), source.height - bounds.Top(), // src1
+ 0, bounds.Height(), // dst0
+ bounds.Width(), 0, // dst1
+ GL_COLOR_BUFFER_BIT, GL_NEAREST //
+ );
+
+ glBindTexture(GL_TEXTURE_2D, render_texture);
+
+ const Gfx::FramebufferData& texture_source = destination;
+ glBindFramebuffer(GL_READ_FRAMEBUFFER, texture_source.framebuffer);
+ glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, bounds.Width(), bounds.Height());
+
+ SetScissor(bounds);
+ glBindFramebuffer(GL_FRAMEBUFFER, render_layers.GetTopLayer().framebuffer);
+ Gfx::CheckGLError("SaveLayerAsTexture");
+
+ return (Rml::TextureHandle)render_texture;
+}
+
+Rml::CompiledFilterHandle RenderInterface_GL3::SaveLayerAsMaskImage()
+{
+ BlitLayerToPostprocessPrimary(render_layers.GetTopLayerHandle());
+
+ const Gfx::FramebufferData& source = render_layers.GetPostprocessPrimary();
+ const Gfx::FramebufferData& destination = render_layers.GetBlendMask();
+
+ glBindFramebuffer(GL_FRAMEBUFFER, destination.framebuffer);
+ BindTexture(source);
+ UseProgram(ProgramId::Passthrough);
+ glDisable(GL_BLEND);
+
+ DrawFullscreenQuad();
+
+ glEnable(GL_BLEND);
+ glBindFramebuffer(GL_FRAMEBUFFER, render_layers.GetTopLayer().framebuffer);
+ Gfx::CheckGLError("SaveLayerAsMaskImage");
+
+ CompiledFilter filter = {};
+ filter.type = FilterType::MaskImage;
+ return reinterpret_cast<Rml::CompiledFilterHandle>(new CompiledFilter(std::move(filter)));
+}
+
+void RenderInterface_GL3::UseProgram(ProgramId program_id)
+{
+ RMLUI_ASSERT(program_data);
+ if (active_program != program_id)
+ {
+ if (program_id != ProgramId::None)
+ glUseProgram(program_data->programs[program_id]);
+ active_program = program_id;
+ }
+}
+
+int RenderInterface_GL3::GetUniformLocation(UniformId uniform_id) const
+{
+ return program_data->uniforms.Get(active_program, uniform_id);
+}
+
+void RenderInterface_GL3::SubmitTransformUniform(Rml::Vector2f translation)
+{
+ static_assert((size_t)ProgramId::Count < MaxNumPrograms, "Maximum number of programs exceeded.");
+ const size_t program_index = (size_t)active_program;
+
+ if (program_transform_dirty.test(program_index))
+ {
+ glUniformMatrix4fv(GetUniformLocation(UniformId::Transform), 1, false, transform.data());
+ program_transform_dirty.set(program_index, false);
+ }
+
+ glUniform2fv(GetUniformLocation(UniformId::Translate), 1, &translation.x);
+
+ Gfx::CheckGLError("SubmitTransformUniform");
+}
+
+RenderInterface_GL3::RenderLayerStack::RenderLayerStack()
+{
+ fb_postprocess.resize(4);
+}
+
+RenderInterface_GL3::RenderLayerStack::~RenderLayerStack()
+{
+ DestroyFramebuffers();
+}
+
+Rml::LayerHandle RenderInterface_GL3::RenderLayerStack::PushLayer()
+{
+ RMLUI_ASSERT(layers_size <= (int)fb_layers.size());
+
+ if (layers_size == (int)fb_layers.size())
+ {
+ // All framebuffers should share a single stencil buffer.
+ GLuint shared_depth_stencil = (fb_layers.empty() ? 0 : fb_layers.front().depth_stencil_buffer);
+
+ fb_layers.push_back(Gfx::FramebufferData{});
+ Gfx::CreateFramebuffer(fb_layers.back(), width, height, RMLUI_NUM_MSAA_SAMPLES, Gfx::FramebufferAttachment::DepthStencil,
+ shared_depth_stencil);
+ }
+
+ layers_size += 1;
+ return GetTopLayerHandle();
+}
+
+void RenderInterface_GL3::RenderLayerStack::PopLayer()
+{
+ RMLUI_ASSERT(layers_size > 0);
+ layers_size -= 1;
+}
+
+const Gfx::FramebufferData& RenderInterface_GL3::RenderLayerStack::GetLayer(Rml::LayerHandle layer) const
+{
+ RMLUI_ASSERT((size_t)layer < (size_t)layers_size);
+ return fb_layers[layer];
+}
+
+const Gfx::FramebufferData& RenderInterface_GL3::RenderLayerStack::GetTopLayer() const
+{
+ return GetLayer(GetTopLayerHandle());
+}
+
+Rml::LayerHandle RenderInterface_GL3::RenderLayerStack::GetTopLayerHandle() const
+{
+ RMLUI_ASSERT(layers_size > 0);
+ return static_cast<Rml::LayerHandle>(layers_size - 1);
+}
+
+void RenderInterface_GL3::RenderLayerStack::SwapPostprocessPrimarySecondary()
+{
+ std::swap(fb_postprocess[0], fb_postprocess[1]);
+}
+
+void RenderInterface_GL3::RenderLayerStack::BeginFrame(int new_width, int new_height)
+{
+ RMLUI_ASSERT(layers_size == 0);
+
+ if (new_width != width || new_height != height)
+ {
+ width = new_width;
+ height = new_height;
+
+ DestroyFramebuffers();
+ }
+
+ PushLayer();
+}
+
+void RenderInterface_GL3::RenderLayerStack::EndFrame()
+{
+ RMLUI_ASSERT(layers_size == 1);
+ PopLayer();
+}
+
+void RenderInterface_GL3::RenderLayerStack::DestroyFramebuffers()
+{
+ RMLUI_ASSERTMSG(layers_size == 0, "Do not call this during frame rendering, that is, between BeginFrame() and EndFrame().");
+
+ for (Gfx::FramebufferData& fb : fb_layers)
+ Gfx::DestroyFramebuffer(fb);
+
+ fb_layers.clear();
+
+ for (Gfx::FramebufferData& fb : fb_postprocess)
+ Gfx::DestroyFramebuffer(fb);
+}
+
+const Gfx::FramebufferData& RenderInterface_GL3::RenderLayerStack::EnsureFramebufferPostprocess(int index)
+{
+ RMLUI_ASSERT(index < (int)fb_postprocess.size())
+ Gfx::FramebufferData& fb = fb_postprocess[index];
+ if (!fb.framebuffer)
+ Gfx::CreateFramebuffer(fb, width, height, 0, Gfx::FramebufferAttachment::None, 0);
+ return fb;
+}
+
+const Rml::Matrix4f& RenderInterface_GL3::GetTransform() const
+{
+ return transform;
+}
+
+void RenderInterface_GL3::ResetProgram()
+{
+ UseProgram(ProgramId::None);
+}
+
+bool RmlGL3::Initialize(Rml::String* out_message)
+{
+#if defined(UNBOX_RMLUI_GLES)
+ if (out_message)
+ *out_message = "Started native OpenGL ES 3.2 renderer.";
+#elif defined(RMLUI_PLATFORM_EMSCRIPTEN)
+ if (out_message)
+ *out_message = "Started Emscripten WebGL renderer.";
+#elif defined(__ANDROID__)
+ if (out_message)
+ *out_message = "Started OpenGL ES 3 renderer.";
+#elif !defined RMLUI_GL3_CUSTOM_LOADER
+ const int gl_version = gladLoaderLoadGL();
+ if (gl_version == 0)
+ {
+ if (out_message)
+ *out_message = "Failed to initialize OpenGL context.";
+ return false;
+ }
+
+ if (out_message)
+ *out_message = Rml::CreateString("Loaded OpenGL %d.%d.", GLAD_VERSION_MAJOR(gl_version), GLAD_VERSION_MINOR(gl_version));
+#endif
+
+ return true;
+}
+
+void RmlGL3::Shutdown()
+{
+#if !defined(UNBOX_RMLUI_GLES) && !defined(RMLUI_PLATFORM_EMSCRIPTEN) && !defined(__ANDROID__) && !defined(RMLUI_GL3_CUSTOM_LOADER)
+ gladLoaderUnloadGL();
+#endif
+}
diff --git a/packages/kernel/src/rmlui_renderer_gl3.h b/packages/kernel/src/rmlui_renderer_gl3.h
new file mode 100644
index 0000000..06cd152
--- /dev/null
+++ b/packages/kernel/src/rmlui_renderer_gl3.h
@@ -0,0 +1,227 @@
+#pragma once
+
+#include <RmlUi/Core/RenderInterface.h>
+#include <RmlUi/Core/Types.h>
+#include <bitset>
+
+enum class ProgramId;
+enum class UniformId;
+class RenderLayerStack;
+namespace Gfx {
+struct ProgramData;
+struct FramebufferData;
+} // namespace Gfx
+
+class RenderInterface_GL3 : public Rml::RenderInterface {
+public:
+ RenderInterface_GL3();
+ ~RenderInterface_GL3();
+
+ // Returns true if the renderer was successfully constructed.
+ explicit operator bool() const { return static_cast<bool>(program_data); }
+
+ // The viewport should be updated whenever the window size changes.
+ void SetViewport(int viewport_width, int viewport_height, int viewport_offset_x = 0, int viewport_offset_y = 0);
+
+ // unbox: redirect EndFrame()'s final composite from the default
+ // framebuffer (0) into the given FBO name. The bridge points this at its
+ // own offscreen, wlr_buffer-backed FBO so the result is compositable.
+ // When flip_y is true, the final composite is vertically flipped so the
+ // output FBO is top-row-first (wlr_buffer convention) despite GL's
+ // bottom-left framebuffer origin.
+ void SetOutputFramebuffer(unsigned int framebuffer, bool flip_y = false)
+ {
+ output_framebuffer = framebuffer;
+ output_flip_y = flip_y;
+ }
+
+ // Sets up OpenGL states for taking rendering commands from RmlUi.
+ void BeginFrame();
+ // Draws the result to the backbuffer and restores OpenGL state.
+ void EndFrame();
+
+ // Optional, can be used to clear the active framebuffer.
+ void Clear();
+
+ // -- Inherited from Rml::RenderInterface --
+
+ Rml::CompiledGeometryHandle CompileGeometry(Rml::Span<const Rml::Vertex> vertices, Rml::Span<const int> indices) override;
+ void RenderGeometry(Rml::CompiledGeometryHandle handle, Rml::Vector2f translation, Rml::TextureHandle texture) override;
+ void ReleaseGeometry(Rml::CompiledGeometryHandle handle) override;
+
+ Rml::TextureHandle LoadTexture(Rml::Vector2i& texture_dimensions, const Rml::String& source) override;
+ Rml::TextureHandle GenerateTexture(Rml::Span<const Rml::byte> source_data, Rml::Vector2i source_dimensions) override;
+ void ReleaseTexture(Rml::TextureHandle texture_handle) override;
+
+ void EnableScissorRegion(bool enable) override;
+ void SetScissorRegion(Rml::Rectanglei region) override;
+
+ void EnableClipMask(bool enable) override;
+ void RenderToClipMask(Rml::ClipMaskOperation mask_operation, Rml::CompiledGeometryHandle geometry, Rml::Vector2f translation) override;
+
+ void SetTransform(const Rml::Matrix4f* transform) override;
+
+ Rml::LayerHandle PushLayer() override;
+ void CompositeLayers(Rml::LayerHandle source, Rml::LayerHandle destination, Rml::BlendMode blend_mode,
+ Rml::Span<const Rml::CompiledFilterHandle> filters) override;
+ void PopLayer() override;
+
+ Rml::TextureHandle SaveLayerAsTexture() override;
+
+ Rml::CompiledFilterHandle SaveLayerAsMaskImage() override;
+
+ Rml::CompiledFilterHandle CompileFilter(const Rml::String& name, const Rml::Dictionary& parameters) override;
+ void ReleaseFilter(Rml::CompiledFilterHandle filter) override;
+
+ Rml::CompiledShaderHandle CompileShader(const Rml::String& name, const Rml::Dictionary& parameters) override;
+ void RenderShader(Rml::CompiledShaderHandle shader_handle, Rml::CompiledGeometryHandle geometry_handle, Rml::Vector2f translation,
+ Rml::TextureHandle texture) override;
+ void ReleaseShader(Rml::CompiledShaderHandle effect_handle) override;
+
+ // Can be passed to RenderGeometry() to enable texture rendering without changing the bound texture.
+ static constexpr Rml::TextureHandle TextureEnableWithoutBinding = Rml::TextureHandle(-1);
+ // Can be passed to RenderGeometry() to leave the bound texture and used program unchanged.
+ static constexpr Rml::TextureHandle TexturePostprocess = Rml::TextureHandle(-2);
+
+ // -- Utility functions for clients --
+
+ const Rml::Matrix4f& GetTransform() const;
+ void ResetProgram();
+
+private:
+ void UseProgram(ProgramId program_id);
+ int GetUniformLocation(UniformId uniform_id) const;
+ void SubmitTransformUniform(Rml::Vector2f translation);
+
+ void BlitLayerToPostprocessPrimary(Rml::LayerHandle layer_handle);
+ void RenderFilters(Rml::Span<const Rml::CompiledFilterHandle> filter_handles);
+
+ void SetScissor(Rml::Rectanglei region, bool vertically_flip = false);
+
+ void DrawFullscreenQuad();
+ void DrawFullscreenQuad(Rml::Vector2f uv_offset, Rml::Vector2f uv_scaling = Rml::Vector2f(1.f));
+
+ void RenderBlur(float sigma, const Gfx::FramebufferData& source_destination, const Gfx::FramebufferData& temp, Rml::Rectanglei window_flipped);
+
+ static constexpr size_t MaxNumPrograms = 32;
+ std::bitset<MaxNumPrograms> program_transform_dirty;
+
+ Rml::Matrix4f transform;
+ Rml::Matrix4f projection;
+
+ ProgramId active_program = {};
+ Rml::Rectanglei scissor_state;
+
+ int viewport_width = 0;
+ int viewport_height = 0;
+ int viewport_offset_x = 0;
+ int viewport_offset_y = 0;
+
+ // unbox: EndFrame() composite target (0 = default backbuffer) and whether
+ // to vertically flip into it (GL bottom-left origin -> top-first buffer).
+ unsigned int output_framebuffer = 0;
+ bool output_flip_y = false;
+
+ Rml::CompiledGeometryHandle fullscreen_quad_geometry = {};
+
+ Rml::UniquePtr<const Gfx::ProgramData> program_data;
+
+ /*
+ Manages render targets, including the layer stack and postprocessing framebuffers.
+
+ Layers can be pushed and popped, creating new framebuffers as needed. Typically, geometry is rendered to the top
+ layer. The layer framebuffers may have MSAA enabled.
+
+ Postprocessing framebuffers are separate from the layers, and are commonly used to apply texture-wide effects
+ such as filters. They are used both as input and output during rendering, and do not use MSAA.
+ */
+ class RenderLayerStack {
+ public:
+ RenderLayerStack();
+ ~RenderLayerStack();
+
+ // Push a new layer. All references to previously retrieved layers are invalidated.
+ Rml::LayerHandle PushLayer();
+
+ // Pop the top layer. All references to previously retrieved layers are invalidated.
+ void PopLayer();
+
+ const Gfx::FramebufferData& GetLayer(Rml::LayerHandle layer) const;
+ const Gfx::FramebufferData& GetTopLayer() const;
+ Rml::LayerHandle GetTopLayerHandle() const;
+
+ const Gfx::FramebufferData& GetPostprocessPrimary() { return EnsureFramebufferPostprocess(0); }
+ const Gfx::FramebufferData& GetPostprocessSecondary() { return EnsureFramebufferPostprocess(1); }
+ const Gfx::FramebufferData& GetPostprocessTertiary() { return EnsureFramebufferPostprocess(2); }
+ const Gfx::FramebufferData& GetBlendMask() { return EnsureFramebufferPostprocess(3); }
+
+ void SwapPostprocessPrimarySecondary();
+
+ void BeginFrame(int new_width, int new_height);
+ void EndFrame();
+
+ private:
+ void DestroyFramebuffers();
+ const Gfx::FramebufferData& EnsureFramebufferPostprocess(int index);
+
+ int width = 0, height = 0;
+
+ // The number of active layers is manually tracked since we re-use the framebuffers stored in the fb_layers stack.
+ int layers_size = 0;
+
+ Rml::Vector<Gfx::FramebufferData> fb_layers;
+ Rml::Vector<Gfx::FramebufferData> fb_postprocess;
+ };
+
+ RenderLayerStack render_layers;
+
+ struct GLStateBackup {
+ bool enable_cull_face;
+ bool enable_blend;
+ bool enable_stencil_test;
+ bool enable_scissor_test;
+ bool enable_depth_test;
+
+ int viewport[4];
+ int scissor[4];
+
+ int active_texture;
+
+ int stencil_clear_value;
+ float color_clear_value[4];
+ unsigned char color_writemask[4];
+
+ int blend_equation_rgb;
+ int blend_equation_alpha;
+ int blend_src_rgb;
+ int blend_dst_rgb;
+ int blend_src_alpha;
+ int blend_dst_alpha;
+
+ struct Stencil {
+ int func;
+ int ref;
+ int value_mask;
+ int writemask;
+ int fail;
+ int pass_depth_fail;
+ int pass_depth_pass;
+ };
+ Stencil stencil_front;
+ Stencil stencil_back;
+ };
+ GLStateBackup glstate_backup = {};
+};
+
+/**
+ Helper functions for the OpenGL 3 renderer.
+ */
+namespace RmlGL3 {
+
+// Loads OpenGL functions. Optionally, the out message describes the loaded GL version or an error message on failure.
+bool Initialize(Rml::String* out_message = nullptr);
+
+// Unloads OpenGL functions.
+void Shutdown();
+
+} // namespace RmlGL3
diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp
index d4d6b56..df24f1f 100644
--- a/packages/kernel/src/server.cpp
+++ b/packages/kernel/src/server.cpp
@@ -58,6 +58,14 @@ void Server::terminate() {
wl_display_terminate(impl_->display);
}
+auto Server::ui_spike_frame_count() const -> int {
+ return impl_->ui_spike != nullptr ? impl_->ui_spike->frame_count() : 0;
+}
+
+auto Server::ui_spike_orientation() const -> int {
+ return impl_->ui_spike != nullptr ? impl_->ui_spike->check_orientation() : 0;
+}
+
// ---- Impl lifecycle --------------------------------------------------------
void Server::Impl::init() {
@@ -112,6 +120,10 @@ void Server::Impl::init() {
throw std::runtime_error("failed to start the wlr_backend");
}
+ if (options.ui_spike) {
+ start_ui_spike();
+ }
+
if (!options.startup_cmd.empty()) {
if (fork() == 0) {
// Child only: don't pollute our own environment.
@@ -123,7 +135,29 @@ void Server::Impl::init() {
}
}
+void Server::Impl::start_ui_spike() {
+ // The bridge needs the wlr renderer's EGLDisplay to build its sibling
+ // GLES 3.2 context. Only the gles2 renderer exposes one; under the
+ // pixman renderer (e.g. headless CI) there is no GL path, so the spike
+ // stays disabled — slice-2 behaviour is preserved.
+ if (!wlr_renderer_is_gles2(renderer)) {
+ wlr_log(WLR_INFO, "ui-spike: renderer is not gles2; spike disabled");
+ return;
+ }
+ wlr_egl* egl = wlr_gles2_renderer_get_egl(renderer);
+ if (egl == nullptr) {
+ wlr_log(WLR_ERROR, "ui-spike: gles2 renderer has no wlr_egl");
+ return;
+ }
+ EGLDisplay display_egl = wlr_egl_get_display(egl);
+ ui_spike = UiSpike::create(&scene->tree, display_egl, allocator, renderer);
+}
+
void Server::Impl::shutdown() {
+ // Slice-3 spike: tear down before scene/renderer/allocator die (it owns
+ // a scene node, GL objects on a sibling context, and borrows the others).
+ ui_spike.reset();
+
if (display != nullptr) {
wl_display_destroy_clients(display); // fires toplevel/popup destroy events
}
@@ -199,6 +233,12 @@ void Server::Impl::handle_new_output(wlr_output* wlr_output) {
outputs.push_back(std::move(owned));
output->frame.connect(wlr_output->events.frame, [this, output](void*) {
+ // Slice-3 spike: render the RMLUi document if dirty, before commit so
+ // its damage is picked up this frame. Cheap no-op when disabled.
+ if (ui_spike != nullptr) {
+ ui_spike->tick();
+ }
+
wlr_scene_output* scene_output = wlr_scene_get_scene_output(scene, output->output);
wlr_scene_output_commit(scene_output, nullptr);
diff --git a/packages/kernel/src/server_impl.hpp b/packages/kernel/src/server_impl.hpp
index 3abf739..2975537 100644
--- a/packages/kernel/src/server_impl.hpp
+++ b/packages/kernel/src/server_impl.hpp
@@ -4,6 +4,7 @@
#include <unbox/kernel/wlr.hpp>
#include "listener.hpp"
+#include "ui_spike.hpp"
#include <cstdint>
#include <list>
@@ -80,6 +81,12 @@ struct Server::Impl {
wlr_seat* seat = nullptr;
std::string socket;
+ // Slice-3 spike: RMLUi -> wlr_scene bridge. Null unless options.ui_spike
+ // and the bridge started; a started-but-disabled bridge is non-null but
+ // reports Plan::Disabled. Owned here; torn down in shutdown() BEFORE the
+ // scene/renderer/allocator die.
+ std::unique_ptr<UiSpike> ui_spike;
+
// Ownership (RAII teardown); drained naturally during shutdown by the
// destroy events wl_display_destroy_clients / backend destroy fire.
std::list<std::unique_ptr<Output>> outputs;
@@ -135,6 +142,7 @@ struct Server::Impl {
void init(); // throws std::runtime_error on any component failure
void shutdown();
void handle_new_output(wlr_output* output);
+ void start_ui_spike(); // slice-3 spike; never throws, may no-op
// toplevel.cpp
void handle_new_toplevel(wlr_xdg_toplevel* toplevel);
diff --git a/packages/kernel/src/ui_spike.cpp b/packages/kernel/src/ui_spike.cpp
new file mode 100644
index 0000000..5d18c07
--- /dev/null
+++ b/packages/kernel/src/ui_spike.cpp
@@ -0,0 +1,688 @@
+#include "ui_spike.hpp"
+
+#include "rmlui_renderer_gl3.h"
+
+#include <RmlUi/Core/Context.h>
+#include <RmlUi/Core/Core.h>
+#include <RmlUi/Core/DataModelHandle.h>
+#include <RmlUi/Core/ElementDocument.h>
+#include <RmlUi/Core/SystemInterface.h>
+
+// The kernel owns GL; system EGL/GLES headers are allowed here (brief).
+// wlr.hpp (via ui_spike.hpp) already pulled <EGL/egl.h>+<EGL/eglext.h>
+// through wlr/render/egl.h, and GLES through the adapted renderer; we add
+// the dmabuf import entrypoints explicitly.
+#include <EGL/egl.h>
+#include <EGL/eglext.h>
+#include <GLES3/gl32.h>
+#include <GLES2/gl2ext.h> // glEGLImageTargetTexture2DOES
+
+#include <cstdint>
+#include <cstdlib>
+#include <cstring>
+#include <ctime>
+#include <vector>
+
+namespace unbox::kernel {
+
+namespace {
+
+constexpr int kSpikeWidth = 320;
+constexpr int kSpikeHeight = 200;
+constexpr int kSpikeX = 40; // layout-space origin of the node
+constexpr int kSpikeY = 40;
+
+// DRM FourCC for the buffers we allocate / wrap. ARGB8888 is universally
+// render+sample-able and matches RMLUi's premultiplied RGBA8 output once
+// channel order is accounted for. (FourCC AR24 = little-endian B,G,R,A.)
+constexpr std::uint32_t kDrmFormatArgb8888 = 0x34325241; // 'AR24'
+
+// Distinctive solid bands at the document's top and bottom edges. They are
+// full-width, unique colors that appear NOWHERE else in the document, so the
+// orientation assertion can prove the submitted buffer is upright: the top
+// band must land in the TOP rows of the buffer, the bottom band in the
+// BOTTOM rows. (A vertical flip would swap them — the bug.)
+constexpr int kBandHeight = 12; // px, each band
+// Top band #18e0a0 (teal-green); bottom band #e09018 (amber). Stored as the
+// RGB byte triplets the Plan-B readback produces (R,G,B order).
+constexpr std::uint8_t kTopBandRGB[3] = {0x18, 0xe0, 0xa0};
+constexpr std::uint8_t kBottomBandRGB[3] = {0xe0, 0x90, 0x18};
+
+// In-memory hello-world document. Distinctive top/bottom bands (orientation
+// proof), a title, a live frame counter via data binding, and a button that
+// reacts to hover/click (input proof).
+const char* kHelloRml = R"RML(<rml>
+<head>
+<style>
+body { font-family: "Noto Sans"; background: #1e2230; color: #e8ecff;
+ width: 320px; height: 200px; }
+#topband { display: block; width: 320px; height: 12px; background: #18e0a0; }
+#bottomband { display: block; width: 320px; height: 12px; background: #e09018;
+ position: absolute; bottom: 0px; left: 0px; }
+h1 { font-size: 22px; margin: 16px; color: #9ecbff; }
+p { font-size: 15px; margin: 0 16px 12px 16px; }
+button { font-size: 15px; margin: 16px; padding: 8px 16px;
+ background: #3a4670; color: #ffffff; border-radius: 6px; }
+button:hover { background: #5468b0; }
+button:active { background: #7e93e0; }
+</style>
+</head>
+<body data-model="spike">
+<div id="topband"></div>
+<h1>unbox ui spike</h1>
+<p>frame {{frame}}</p>
+<button>{{label}}</button>
+<div id="bottomband"></div>
+</body>
+</rml>)RML";
+
+// --- SystemInterface: elapsed time + route RmlUi logs to wlr_log ----------
+
+class SpikeSystemInterface final : public Rml::SystemInterface {
+public:
+ auto GetElapsedTime() -> double override {
+ timespec now{};
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ if (start_ == 0.0) {
+ start_ = static_cast<double>(now.tv_sec) + now.tv_nsec / 1e9;
+ }
+ return (static_cast<double>(now.tv_sec) + now.tv_nsec / 1e9) - start_;
+ }
+
+ auto LogMessage(Rml::Log::Type type, const Rml::String& message) -> bool override {
+ const wlr_log_importance imp = (type == Rml::Log::LT_ERROR || type == Rml::Log::LT_ASSERT)
+ ? WLR_ERROR
+ : (type == Rml::Log::LT_WARNING ? WLR_INFO : WLR_DEBUG);
+ wlr_log(imp, "[rmlui] %s", message.c_str());
+ return true;
+ }
+
+private:
+ double start_ = 0.0;
+};
+
+// --- A data-ptr wlr_buffer wrapping heap memory (Plan B target) -----------
+//
+// The wlr GLES2 renderer can sample a WLR_BUFFER_CAP_DATA_PTR buffer (it
+// uploads via begin/end_data_ptr_access). Works on both the headless/pixman
+// and GPU/gles2 backends, which is why this is the robust spike landing.
+
+struct ShmBuffer {
+ wlr_buffer base{};
+ std::vector<std::uint8_t> data;
+ std::uint32_t format = kDrmFormatArgb8888;
+ std::size_t stride = 0;
+ bool dropped = false;
+};
+
+void shm_buffer_destroy(wlr_buffer* wlr_buf) {
+ auto* buf = reinterpret_cast<ShmBuffer*>(wlr_buf);
+ wlr_buffer_finish(&buf->base);
+ delete buf;
+}
+
+auto shm_buffer_begin_data_ptr_access(wlr_buffer* wlr_buf, std::uint32_t /*flags*/, void** data,
+ std::uint32_t* format, std::size_t* stride) -> bool {
+ auto* buf = reinterpret_cast<ShmBuffer*>(wlr_buf);
+ *data = buf->data.data();
+ *format = buf->format;
+ *stride = buf->stride;
+ return true;
+}
+
+void shm_buffer_end_data_ptr_access(wlr_buffer* /*wlr_buf*/) {}
+
+const wlr_buffer_impl kShmBufferImpl = {
+ .destroy = shm_buffer_destroy,
+ .get_dmabuf = nullptr,
+ .get_shm = nullptr,
+ .begin_data_ptr_access = shm_buffer_begin_data_ptr_access,
+ .end_data_ptr_access = shm_buffer_end_data_ptr_access,
+};
+
+auto make_shm_buffer(int width, int height) -> ShmBuffer* {
+ auto* buf = new ShmBuffer();
+ buf->stride = static_cast<std::size_t>(width) * 4;
+ buf->data.assign(buf->stride * static_cast<std::size_t>(height), 0);
+ wlr_buffer_init(&buf->base, &kShmBufferImpl, width, height);
+ return buf;
+}
+
+} // namespace
+
+// --- Impl -----------------------------------------------------------------
+
+struct UiSpike::Impl {
+ EGLDisplay egl_display = EGL_NO_DISPLAY;
+ EGLContext egl_context = EGL_NO_CONTEXT;
+ EGLContext saved_context = EGL_NO_CONTEXT;
+ EGLSurface saved_draw = EGL_NO_SURFACE;
+ EGLSurface saved_read = EGL_NO_SURFACE;
+
+ wlr_allocator* allocator = nullptr; // borrowed (server-owned)
+ wlr_renderer* renderer = nullptr; // borrowed (server-owned)
+
+ // Sibling-context GL objects.
+ GLuint fbo = 0;
+ GLuint color_tex = 0;
+
+ // Plan A (dmabuf) state — populated only if A engages.
+ wlr_buffer* dmabuf = nullptr; // the swapchain-acquired render target
+ EGLImageKHR egl_image = EGL_NO_IMAGE_KHR;
+
+ // Plan B (shm copy) state.
+ ShmBuffer* shm = nullptr;
+ std::vector<std::uint8_t> readback; // glReadPixels scratch
+
+ // RMLUi.
+ std::unique_ptr<SpikeSystemInterface> system;
+ std::unique_ptr<RenderInterface_GL3> render_iface;
+ Rml::Context* context = nullptr; // owned by Rml (RemoveContext)
+ Rml::ElementDocument* document = nullptr;
+ Rml::DataModelHandle model;
+
+ // Data-bound document state.
+ int frame = 0;
+ Rml::String label = "hover me";
+
+ // Scene.
+ wlr_scene_buffer* scene_buffer = nullptr;
+
+ Plan plan = Plan::Disabled;
+ int frame_count = 0;
+
+ // EGL extension entrypoints (loaded once).
+ PFNEGLCREATEIMAGEKHRPROC egl_create_image = nullptr;
+ PFNEGLDESTROYIMAGEKHRPROC egl_destroy_image = nullptr;
+ PFNGLEGLIMAGETARGETTEXTURE2DOESPROC gl_image_target_texture = nullptr;
+
+ bool make_current() {
+ saved_context = eglGetCurrentContext();
+ saved_draw = eglGetCurrentSurface(EGL_DRAW);
+ saved_read = eglGetCurrentSurface(EGL_READ);
+ return eglMakeCurrent(egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, egl_context) == EGL_TRUE;
+ }
+
+ void restore_current() {
+ eglMakeCurrent(egl_display, saved_draw, saved_read, saved_context);
+ }
+
+ bool init(wlr_scene_tree* parent, EGLDisplay display, wlr_allocator* alloc,
+ wlr_renderer* rend);
+ bool try_plan_a();
+ void setup_plan_b();
+ void render_locked();
+ void teardown();
+};
+
+bool UiSpike::Impl::init(wlr_scene_tree* parent, EGLDisplay display, wlr_allocator* alloc,
+ wlr_renderer* rend) {
+ egl_display = display;
+ allocator = alloc;
+ renderer = rend;
+
+ // 1. Sibling GLES 3.2 context sharing the wlr EGLDisplay. No GL object
+ // sharing — buffers cross via dmabuf/EGLImage or CPU copy only, so we
+ // do NOT pass the wlr context as share_context.
+ if (eglBindAPI(EGL_OPENGL_ES_API) != EGL_TRUE) {
+ wlr_log(WLR_ERROR, "ui-spike: eglBindAPI(ES) failed");
+ return false;
+ }
+ const EGLint config_attribs[] = {
+ EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
+ EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8,
+ EGL_NONE,
+ };
+ EGLConfig config = nullptr;
+ EGLint num_config = 0;
+ if (eglChooseConfig(egl_display, config_attribs, &config, 1, &num_config) != EGL_TRUE ||
+ num_config < 1) {
+ wlr_log(WLR_ERROR, "ui-spike: eglChooseConfig found no ES3 config");
+ return false;
+ }
+ const EGLint ctx_attribs[] = {
+ EGL_CONTEXT_MAJOR_VERSION, 3, EGL_CONTEXT_MINOR_VERSION, 2, EGL_NONE,
+ };
+ egl_context = eglCreateContext(egl_display, config, EGL_NO_CONTEXT, ctx_attribs);
+ if (egl_context == EGL_NO_CONTEXT) {
+ wlr_log(WLR_ERROR, "ui-spike: eglCreateContext(ES 3.2) failed (0x%x)", eglGetError());
+ return false;
+ }
+
+ if (!make_current()) {
+ wlr_log(WLR_ERROR, "ui-spike: eglMakeCurrent (surfaceless) failed (0x%x)", eglGetError());
+ restore_current();
+ return false;
+ }
+
+ // Load EGLImage entrypoints for the Plan-A attempt.
+ egl_create_image =
+ reinterpret_cast<PFNEGLCREATEIMAGEKHRPROC>(eglGetProcAddress("eglCreateImageKHR"));
+ egl_destroy_image =
+ reinterpret_cast<PFNEGLDESTROYIMAGEKHRPROC>(eglGetProcAddress("eglDestroyImageKHR"));
+ gl_image_target_texture = reinterpret_cast<PFNGLEGLIMAGETARGETTEXTURE2DOESPROC>(
+ eglGetProcAddress("glEGLImageTargetTexture2DOES"));
+
+ // 2. RMLUi render interface (our GLES3-adapted GL3 backend).
+ Rml::String gl_msg;
+ if (!RmlGL3::Initialize(&gl_msg)) {
+ wlr_log(WLR_ERROR, "ui-spike: RmlGL3::Initialize failed");
+ restore_current();
+ return false;
+ }
+ wlr_log(WLR_INFO, "ui-spike: %s", gl_msg.c_str());
+
+ render_iface = std::make_unique<RenderInterface_GL3>();
+ if (!*render_iface) {
+ wlr_log(WLR_ERROR, "ui-spike: RenderInterface_GL3 construction failed");
+ restore_current();
+ return false;
+ }
+ render_iface->SetViewport(kSpikeWidth, kSpikeHeight);
+
+ // 3. Offscreen FBO + color target. Plan A first, Plan B on any failure.
+ glGenFramebuffers(1, &fbo);
+ if (!try_plan_a()) {
+ setup_plan_b();
+ }
+ // flip_y: the FBO color attachment (dmabuf in Plan A, GL texture read
+ // back in Plan B) is sampled/scanned-out row 0 = top, but GL renders with
+ // a bottom-left origin. Flip the final composite so the submitted buffer
+ // is upright; display then matches document coords, so pointer input is
+ // forwarded unflipped (on-screen button == document button).
+ render_iface->SetOutputFramebuffer(fbo, /*flip_y=*/true);
+
+ // Verify the FBO is complete before committing to RMLUi init.
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ const GLenum fb_status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ if (fb_status != GL_FRAMEBUFFER_COMPLETE) {
+ wlr_log(WLR_ERROR, "ui-spike: output FBO incomplete (0x%x)", fb_status);
+ restore_current();
+ return false;
+ }
+
+ // 4. RMLUi core + font + context + document.
+ system = std::make_unique<SpikeSystemInterface>();
+ Rml::SetSystemInterface(system.get());
+ Rml::SetRenderInterface(render_iface.get());
+ if (!Rml::Initialise()) {
+ wlr_log(WLR_ERROR, "ui-spike: Rml::Initialise failed");
+ restore_current();
+ return false;
+ }
+
+ if (!Rml::LoadFontFace("/usr/share/fonts/noto/NotoSans-Regular.ttf")) {
+ wlr_log(WLR_INFO, "ui-spike: NotoSans not found; disabling spike gracefully");
+ Rml::Shutdown();
+ restore_current();
+ return false;
+ }
+
+ context = Rml::CreateContext("spike", Rml::Vector2i(kSpikeWidth, kSpikeHeight));
+ if (context == nullptr) {
+ wlr_log(WLR_ERROR, "ui-spike: CreateContext failed");
+ Rml::Shutdown();
+ restore_current();
+ return false;
+ }
+
+ if (Rml::DataModelConstructor ctor = context->CreateDataModel("spike")) {
+ ctor.Bind("frame", &frame);
+ ctor.Bind("label", &label);
+ model = ctor.GetModelHandle();
+ }
+
+ document = context->LoadDocumentFromMemory(kHelloRml);
+ if (document == nullptr) {
+ wlr_log(WLR_ERROR, "ui-spike: LoadDocumentFromMemory failed");
+ Rml::Shutdown();
+ restore_current();
+ return false;
+ }
+ document->Show();
+
+ // 5. Scene node. Start with a transparent/empty buffer; tick() fills it.
+ scene_buffer = wlr_scene_buffer_create(parent, nullptr);
+ if (scene_buffer == nullptr) {
+ wlr_log(WLR_ERROR, "ui-spike: wlr_scene_buffer_create failed");
+ Rml::Shutdown();
+ restore_current();
+ return false;
+ }
+ wlr_scene_node_set_position(&scene_buffer->node, kSpikeX, kSpikeY);
+
+ restore_current();
+ wlr_log(WLR_INFO, "ui-spike: bridge up (plan %s, %dx%d)",
+ plan == Plan::Dmabuf ? "A/dmabuf" : "B/shm-copy", kSpikeWidth, kSpikeHeight);
+ return true;
+}
+
+// Plan A: allocate a dmabuf wlr_buffer via the server allocator, import it
+// into the sibling context as an EGLImage, bind that as the FBO color
+// attachment. Returns false (cleaning up) on any failure so init() falls to B.
+bool UiSpike::Impl::try_plan_a() {
+ // Spike instrumentation: force the Plan-B fallback for testing the CPU
+ // copy path even on hardware where Plan A works. Harmless in production.
+ if (std::getenv("UNBOX_UI_SPIKE_FORCE_SHM") != nullptr) {
+ wlr_log(WLR_INFO, "ui-spike: plan A skipped — UNBOX_UI_SPIKE_FORCE_SHM set");
+ return false;
+ }
+ if ((allocator->buffer_caps & WLR_BUFFER_CAP_DMABUF) == 0) {
+ wlr_log(WLR_INFO, "ui-spike: plan A skipped — allocator has no DMABUF cap");
+ return false;
+ }
+ if (egl_create_image == nullptr || gl_image_target_texture == nullptr) {
+ wlr_log(WLR_INFO, "ui-spike: plan A skipped — no EGLImage dmabuf-import entrypoints");
+ return false;
+ }
+ const char* exts = eglQueryString(egl_display, EGL_EXTENSIONS);
+ if (exts == nullptr || std::strstr(exts, "EGL_EXT_image_dma_buf_import") == nullptr) {
+ wlr_log(WLR_INFO, "ui-spike: plan A skipped — no EGL_EXT_image_dma_buf_import");
+ return false;
+ }
+
+ // Allocate one dmabuf via the allocator using a LINEAR/INVALID modifier
+ // list (legacy-driver-safe; crocus is fine with linear).
+ wlr_drm_format fmt{};
+ fmt.format = kDrmFormatArgb8888;
+ const std::uint64_t modifiers[] = {0 /* DRM_FORMAT_MOD_LINEAR */};
+ fmt.len = 1;
+ fmt.capacity = 1;
+ fmt.modifiers = const_cast<std::uint64_t*>(modifiers);
+
+ wlr_buffer* buf = wlr_allocator_create_buffer(allocator, kSpikeWidth, kSpikeHeight, &fmt);
+ if (buf == nullptr) {
+ wlr_log(WLR_INFO, "ui-spike: plan A — allocator could not create dmabuf");
+ return false;
+ }
+
+ wlr_dmabuf_attributes attribs{};
+ if (!wlr_buffer_get_dmabuf(buf, &attribs) || attribs.n_planes < 1) {
+ wlr_log(WLR_INFO, "ui-spike: plan A — buffer has no dmabuf attrs");
+ wlr_buffer_drop(buf);
+ return false;
+ }
+
+ // Build the EGLImage from the dmabuf (single-plane fast path).
+ EGLint img_attribs[] = {
+ EGL_WIDTH, attribs.width,
+ EGL_HEIGHT, attribs.height,
+ EGL_LINUX_DRM_FOURCC_EXT, static_cast<EGLint>(attribs.format),
+ EGL_DMA_BUF_PLANE0_FD_EXT, attribs.fd[0],
+ EGL_DMA_BUF_PLANE0_OFFSET_EXT, static_cast<EGLint>(attribs.offset[0]),
+ EGL_DMA_BUF_PLANE0_PITCH_EXT, static_cast<EGLint>(attribs.stride[0]),
+ EGL_NONE,
+ };
+ egl_image = egl_create_image(egl_display, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT,
+ static_cast<EGLClientBuffer>(nullptr), img_attribs);
+ if (egl_image == EGL_NO_IMAGE_KHR) {
+ wlr_log(WLR_INFO, "ui-spike: plan A — eglCreateImageKHR failed (0x%x)", eglGetError());
+ wlr_buffer_drop(buf);
+ return false;
+ }
+
+ glGenTextures(1, &color_tex);
+ glBindTexture(GL_TEXTURE_2D, color_tex);
+ gl_image_target_texture(GL_TEXTURE_2D, static_cast<GLeglImageOES>(egl_image));
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color_tex, 0);
+ const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ if (status != GL_FRAMEBUFFER_COMPLETE) {
+ wlr_log(WLR_INFO, "ui-spike: plan A — FBO from EGLImage incomplete (0x%x)", status);
+ egl_destroy_image(egl_display, egl_image);
+ egl_image = EGL_NO_IMAGE_KHR;
+ glDeleteTextures(1, &color_tex);
+ color_tex = 0;
+ wlr_buffer_drop(buf);
+ return false;
+ }
+
+ dmabuf = buf;
+ plan = Plan::Dmabuf;
+ wlr_log(WLR_INFO, "ui-spike: plan A engaged (dmabuf-backed FBO)");
+ return true;
+}
+
+// Plan B: a plain GL texture color attachment; results read back to a
+// data-ptr wlr_buffer with glReadPixels each frame.
+void UiSpike::Impl::setup_plan_b() {
+ glGenTextures(1, &color_tex);
+ glBindTexture(GL_TEXTURE_2D, color_tex);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kSpikeWidth, kSpikeHeight, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, nullptr);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color_tex, 0);
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+
+ shm = make_shm_buffer(kSpikeWidth, kSpikeHeight);
+ readback.assign(static_cast<std::size_t>(kSpikeWidth) * kSpikeHeight * 4, 0);
+ plan = Plan::ShmCopy;
+ wlr_log(WLR_INFO, "ui-spike: plan B engaged (FBO + glReadPixels -> shm)");
+}
+
+// Render one dirty frame. Caller holds the sibling context current.
+void UiSpike::Impl::render_locked() {
+ // Tick the bound state; dirtying drives the repeated render proof.
+ frame += 1;
+ if (model) {
+ model.DirtyVariable("frame");
+ }
+
+ context->Update();
+ render_iface->BeginFrame();
+ render_iface->Clear();
+ context->Render();
+ render_iface->EndFrame(); // composites into `fbo` (SetOutputFramebuffer)
+
+ if (plan == Plan::Dmabuf) {
+ // The wlr renderer will sample the dmabuf directly; ensure all GL
+ // writes have landed before the compositor reads it. A spike uses
+ // glFinish; the real substrate will use an EGL fence.
+ glFinish();
+ wlr_scene_buffer_set_buffer(scene_buffer, dmabuf);
+ } else {
+ // Plan B: read the FBO back into the data-ptr buffer.
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glReadPixels(0, 0, kSpikeWidth, kSpikeHeight, GL_RGBA, GL_UNSIGNED_BYTE, readback.data());
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+
+ // RMLUi outputs premultiplied RGBA8 (R,G,B,A byte order). The shm
+ // buffer is FourCC AR24 = little-endian {B,G,R,A}. Swap R<->B; the
+ // result is already premultiplied which wlroots expects.
+ const std::size_t px = static_cast<std::size_t>(kSpikeWidth) * kSpikeHeight;
+ std::uint8_t* dst = shm->data.data();
+ const std::uint8_t* src = readback.data();
+ for (std::size_t i = 0; i < px; ++i) {
+ dst[i * 4 + 0] = src[i * 4 + 2]; // B
+ dst[i * 4 + 1] = src[i * 4 + 1]; // G
+ dst[i * 4 + 2] = src[i * 4 + 0]; // R
+ dst[i * 4 + 3] = src[i * 4 + 3]; // A
+ }
+ wlr_scene_buffer_set_buffer(scene_buffer, &shm->base);
+ }
+
+ frame_count += 1;
+}
+
+void UiSpike::Impl::teardown() {
+ // RMLUi teardown needs the sibling context current (GL deletes).
+ const bool ok = make_current();
+
+ if (scene_buffer != nullptr) {
+ wlr_scene_node_destroy(&scene_buffer->node);
+ scene_buffer = nullptr;
+ }
+
+ if (context != nullptr) {
+ // Document is owned by the context; Shutdown tears everything down.
+ Rml::Shutdown();
+ context = nullptr;
+ document = nullptr;
+ }
+ render_iface.reset();
+
+ if (color_tex != 0) {
+ glDeleteTextures(1, &color_tex);
+ color_tex = 0;
+ }
+ if (fbo != 0) {
+ glDeleteFramebuffers(1, &fbo);
+ fbo = 0;
+ }
+ if (egl_image != EGL_NO_IMAGE_KHR && egl_destroy_image != nullptr) {
+ egl_destroy_image(egl_display, egl_image);
+ egl_image = EGL_NO_IMAGE_KHR;
+ }
+ if (dmabuf != nullptr) {
+ wlr_buffer_drop(dmabuf);
+ dmabuf = nullptr;
+ }
+ if (shm != nullptr) {
+ wlr_buffer_drop(&shm->base); // triggers shm_buffer_destroy -> delete
+ shm = nullptr;
+ }
+
+ if (ok) {
+ restore_current();
+ }
+ if (egl_context != EGL_NO_CONTEXT) {
+ eglDestroyContext(egl_display, egl_context);
+ egl_context = EGL_NO_CONTEXT;
+ }
+}
+
+// --- UiSpike (public-ish private surface) ---------------------------------
+
+auto UiSpike::create(wlr_scene_tree* parent, EGLDisplay egl_display, wlr_allocator* allocator,
+ wlr_renderer* renderer) -> std::unique_ptr<UiSpike> {
+ auto impl = std::make_unique<Impl>();
+ if (!impl->init(parent, egl_display, allocator, renderer)) {
+ impl->teardown(); // safe to call after partial init
+ // Hand back a Disabled bridge (never throws, never aborts the server).
+ auto disabled = std::make_unique<Impl>();
+ disabled->plan = Plan::Disabled;
+ return std::unique_ptr<UiSpike>(new UiSpike(std::move(disabled)));
+ }
+ return std::unique_ptr<UiSpike>(new UiSpike(std::move(impl)));
+}
+
+UiSpike::UiSpike(std::unique_ptr<Impl> impl) : impl_(std::move(impl)) {}
+
+UiSpike::~UiSpike() {
+ if (impl_->plan != Plan::Disabled) {
+ impl_->teardown();
+ }
+}
+
+void UiSpike::tick() {
+ if (impl_->plan == Plan::Disabled) {
+ return;
+ }
+ // Render every tick (spike fidelity: the frame counter dirties the doc
+ // each call, so the context is always dirty — proving repeated cycles).
+ if (!impl_->make_current()) {
+ return;
+ }
+ impl_->render_locked();
+ impl_->restore_current();
+}
+
+void UiSpike::on_pointer_motion(double sx, double sy) {
+ if (impl_->plan == Plan::Disabled || impl_->context == nullptr) {
+ return;
+ }
+ impl_->context->ProcessMouseMove(static_cast<int>(sx), static_cast<int>(sy), 0);
+}
+
+void UiSpike::on_pointer_button(bool pressed) {
+ if (impl_->plan == Plan::Disabled || impl_->context == nullptr) {
+ return;
+ }
+ if (pressed) {
+ impl_->context->ProcessMouseButtonDown(0, 0);
+ } else {
+ impl_->context->ProcessMouseButtonUp(0, 0);
+ }
+}
+
+auto UiSpike::node() const -> wlr_scene_node* {
+ return impl_->scene_buffer != nullptr ? &impl_->scene_buffer->node : nullptr;
+}
+
+auto UiSpike::plan() const -> Plan {
+ return impl_->plan;
+}
+
+auto UiSpike::frame_count() const -> int {
+ return impl_->frame_count;
+}
+
+auto UiSpike::check_orientation() const -> int {
+ // Only the shm path keeps a CPU readback to inspect, and only after a
+ // frame has been submitted.
+ if (impl_->plan != Plan::ShmCopy || impl_->frame_count == 0) {
+ return 0;
+ }
+ const std::uint8_t* px = impl_->readback.data(); // R,G,B,A, row 0 = top
+ const int w = kSpikeWidth;
+ const int h = kSpikeHeight;
+
+ auto matches = [](const std::uint8_t* p, const std::uint8_t (&c)[3]) {
+ const int dr = static_cast<int>(p[0]) - c[0];
+ const int dg = static_cast<int>(p[1]) - c[1];
+ const int db = static_cast<int>(p[2]) - c[2];
+ return dr * dr + dg * dg + db * db < 24 * 24; // tolerant of AA edges
+ };
+
+ // Count band pixels in the top kBandHeight rows vs the bottom kBandHeight
+ // rows, sampling the full width. Upright => top band dominates the top
+ // rows and bottom band the bottom rows; a flip swaps them.
+ int top_band_in_top = 0;
+ int top_band_in_bottom = 0;
+ int bottom_band_in_top = 0;
+ int bottom_band_in_bottom = 0;
+ for (int row = 0; row < kBandHeight; ++row) {
+ const int top_row = row;
+ const int bot_row = h - 1 - row;
+ for (int x = 0; x < w; ++x) {
+ const std::uint8_t* pt = px + (static_cast<std::size_t>(top_row) * w + x) * 4;
+ const std::uint8_t* pb = px + (static_cast<std::size_t>(bot_row) * w + x) * 4;
+ if (matches(pt, kTopBandRGB)) {
+ ++top_band_in_top;
+ }
+ if (matches(pb, kTopBandRGB)) {
+ ++top_band_in_bottom;
+ }
+ if (matches(pt, kBottomBandRGB)) {
+ ++bottom_band_in_top;
+ }
+ if (matches(pb, kBottomBandRGB)) {
+ ++bottom_band_in_bottom;
+ }
+ }
+ }
+
+ // Need a clear, unambiguous signal in one orientation.
+ const bool upright = top_band_in_top > 100 && bottom_band_in_bottom > 100 &&
+ top_band_in_top > top_band_in_bottom &&
+ bottom_band_in_bottom > bottom_band_in_top;
+ const bool flipped = top_band_in_bottom > 100 && bottom_band_in_top > 100 &&
+ top_band_in_bottom > top_band_in_top &&
+ bottom_band_in_top > bottom_band_in_bottom;
+ if (upright) {
+ return 1;
+ }
+ if (flipped) {
+ return -1;
+ }
+ return 0;
+}
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/src/ui_spike.hpp b/packages/kernel/src/ui_spike.hpp
new file mode 100644
index 0000000..4fdca0b
--- /dev/null
+++ b/packages/kernel/src/ui_spike.hpp
@@ -0,0 +1,77 @@
+#pragma once
+
+#include <unbox/kernel/wlr.hpp>
+
+#include <memory>
+
+// Slice-3 spike: the RMLUi -> wlr_scene bridge (prompts/kernel.md, plan §4).
+// PRIVATE to the kernel; nothing here is a contract. Replaced wholesale by
+// the real ui-substrate contract in slice 4+.
+//
+// A UiSpike owns a sibling GLES 3.2 EGL context (sharing the wlr renderer's
+// EGLDisplay), an offscreen FBO into a wlr_buffer, an RMLUi context rendering
+// a hello-world document, and a wlr_scene_buffer node showing it. It renders
+// only when the RMLUi context is dirty, driven from an output frame handler.
+//
+// Everything runs on the single wl_event_loop thread.
+
+namespace unbox::kernel {
+
+class UiSpike {
+public:
+ // Which compositing plan the bridge landed on (plan §4 / brief A->B->C).
+ enum class Plan {
+ Disabled, // could not start (no font / no GL); server runs as slice-2
+ Dmabuf, // Plan A: dmabuf-backed wlr_buffer imported as EGLImage FBO
+ ShmCopy, // Plan B: FBO + glReadPixels into a data-ptr wlr_buffer
+ };
+
+ // Builds the bridge and attaches a scene node under `parent`. `egl_display`
+ // is the wlr renderer's EGLDisplay (wlr_egl_get_display); the sibling
+ // context shares it. `allocator`/`renderer` are borrowed for the buffer
+ // lifetime of the spike (owned by the server). Never throws: on any
+ // failure it logs and yields a Disabled bridge (frame_count stays 0).
+ static auto create(wlr_scene_tree* parent, EGLDisplay egl_display,
+ wlr_allocator* allocator, wlr_renderer* renderer)
+ -> std::unique_ptr<UiSpike>;
+
+ ~UiSpike();
+ UiSpike(const UiSpike&) = delete;
+ auto operator=(const UiSpike&) -> UiSpike& = delete;
+
+ // Advance + render one frame if the RMLUi context is dirty (ticks the
+ // bound frame counter, which dirties the document every call at spike
+ // fidelity). Submits to the scene with damage. No-op when Disabled.
+ void tick();
+
+ // Crude input proof (NOT the slice-5 routing contract). Coords are
+ // surface-local pixels within the spike node. Forwarded straight to the
+ // RMLUi context; a hover/click makes the document's button react.
+ void on_pointer_motion(double sx, double sy);
+ void on_pointer_button(bool pressed);
+
+ // The scene node's position/size, so the server can hit-test pointer
+ // events against it. Layout coords; node sits at a fixed origin.
+ [[nodiscard]] auto node() const -> wlr_scene_node*;
+
+ [[nodiscard]] auto plan() const -> Plan;
+ [[nodiscard]] auto frame_count() const -> int;
+
+ // Orientation self-check on the submitted buffer (Plan B / shm path only,
+ // where the CPU readback exists). The document carries distinctive solid
+ // bands at its top and bottom edges; this samples the buffer and returns:
+ // +1 upright: top band is in the TOP rows, bottom band in the bottom
+ // -1 flipped: bands are swapped (the bug this fix prevents)
+ // 0 indeterminate: not the shm path, or no frame rendered yet, or the
+ // bands were not found (e.g. spike disabled)
+ // Lets a headless test assert orientation can never silently regress.
+ [[nodiscard]] auto check_orientation() const -> int;
+
+ struct Impl;
+
+private:
+ explicit UiSpike(std::unique_ptr<Impl> impl);
+ std::unique_ptr<Impl> impl_;
+};
+
+} // namespace unbox::kernel
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index 0172901..6f8ce9b 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -27,3 +27,75 @@ TEST_CASE("server boots and shuts down on the headless backend") {
}
// Destruction runs the full tinywl shutdown sequence.
}
+
+TEST_CASE("ui spike defaults off and is the slice-2 server") {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "pixman", 1);
+
+ auto server = unbox::kernel::Server::create({});
+ CHECK(server->ui_spike_frame_count() == 0);
+ for (int i = 0; i < 3; ++i) {
+ CHECK(server->dispatch(10));
+ }
+ CHECK(server->ui_spike_frame_count() == 0);
+}
+
+TEST_CASE("ui spike boots, renders frames, and shuts down cleanly") {
+ // Drive the RMLUi -> wlr_scene bridge on the headless backend with the
+ // gles2 renderer so the real GL path is exercised (Plan A attempted,
+ // Plan B as fallback). The headless backend uses an EGL render node; if
+ // GL is unavailable the bridge disables itself gracefully and frame_count
+ // stays 0 (asserted as the no-crash fallback). A headless output must be
+ // created so the frame handler (which drives tick()) fires.
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+ setenv("WLR_HEADLESS_OUTPUTS", "1", 1);
+
+ auto server = unbox::kernel::Server::create({.ui_spike = true});
+ CHECK(!server->socket_name().empty());
+
+ // Pump enough turns for the headless output to emit frames.
+ for (int i = 0; i < 200; ++i) {
+ CHECK(server->dispatch(10));
+ }
+
+ const int frames = server->ui_spike_frame_count();
+ INFO("ui_spike_frame_count() = ", frames);
+ // Either the bridge ran (frames advanced) or it disabled itself on a
+ // headless box without a usable GL path. Both are acceptable; a crash is
+ // not. Clean shutdown is exercised on destruction below.
+ CHECK(frames >= 0);
+}
+
+TEST_CASE("ui spike submits an upright (non-flipped) buffer") {
+ // Orientation regression guard. The spike document carries distinctive
+ // solid bands at its top and bottom edges; on the CPU-readback (Plan B)
+ // path the bridge inspects the SUBMITTED buffer and reports +1 if the top
+ // band is in the top rows (upright), -1 if vertically flipped. GL's
+ // bottom-left framebuffer origin vs the wlr_buffer top-first convention
+ // makes the flip the default failure mode, so this must never silently
+ // regress. Force the shm path so the readback exists; if GL is
+ // unavailable the spike disables itself and orientation() returns 0
+ // (skipped, not failed — same graceful-degrade contract as above).
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+ setenv("WLR_HEADLESS_OUTPUTS", "1", 1);
+ setenv("UNBOX_UI_SPIKE_FORCE_SHM", "1", 1);
+
+ auto server = unbox::kernel::Server::create({.ui_spike = true});
+ for (int i = 0; i < 200; ++i) {
+ CHECK(server->dispatch(10));
+ }
+
+ const int orient = server->ui_spike_orientation();
+ INFO("ui_spike_orientation() = ", orient);
+ // MUST NOT be flipped. +1 = upright (the bridge ran), 0 = indeterminate
+ // (no GL path on this box). A flip (-1) is the bug and fails here.
+ CHECK(orient != -1);
+ if (server->ui_spike_frame_count() > 0) {
+ // The shm bridge ran: orientation must be positively confirmed upright.
+ CHECK(orient == 1);
+ }
+
+ unsetenv("UNBOX_UI_SPIKE_FORCE_SHM");
+}
diff --git a/tasks.md b/tasks.md
index 43b07d9..d7c6f35 100644
--- a/tasks.md
+++ b/tasks.md
@@ -5,9 +5,10 @@
## Now
-**Next action:** Slice 3 — THE SPIKE: RMLUi→scene bridge (notes/plan.md
-§4). Plan A: GLES 3.2 sibling context → dmabuf-backed FBO → wlr_buffer →
-wlr_scene_buffer. Go/no-go gate for the whole UI design.
+**Next action:** Slice 4 — extension host + contracts: bus, manifests,
+static registration; xdg-shell/layer-shell refactored OUT of the kernel
+into core extensions. Design input: what the spike learned (see slice-3
+row + packages/kernel/kernel.md gotchas).
## Slices
@@ -16,7 +17,7 @@ wlr_scene_buffer. Go/no-go gate for the whole UI design.
| 0 | Harness skeleton | **DONE** 2026-06-12 | All harness md files in place |
| 1 | Bootstrap: toolchain, Meson skeleton, RMLUi subproject compiles, empty kernel links wlroots-0.20 from C++ via the extern-"C" wrapper | **DONE** 2026-06-12 | met: build green; tests 1/1; binary prints wlroots 0.20.1 + RmlUi 6.2, exits 0 |
| 2 | tinywl port: kernel skeleton runs nested under labwc | **DONE** 2026-06-12 | met: nested output WL-1, foot toplevel mapped+focused, GLES2 renderer; touch handlers added (tinywl lacks them); headless boot test green |
-| 3 | **THE SPIKE:** RMLUi→scene bridge | pending | a hello-world RML document composited as a scene node with damage tracking; go/no-go gate |
+| 3 | **THE SPIKE:** RMLUi→scene bridge | **DONE — GO** 2026-06-12 | met: Plan A (dmabuf FBO→wlr_buffer→wlr_scene_buffer) verified nested+headless on HD 4400; Plan B fallback verified; orientation fixed + position-aware guard; input proof on-screen; RSS ≈83 MiB; ASan/UBSan clean in our code (known noise: Mesa leak reports + 2 benign UBSan downcasts inside vendored RMLUi). glFinish→fence and format negotiation deferred to the real substrate (slice 4+) |
| 4 | Extension host + contracts: bus, manifests, static registration; xdg-shell/layer-shell refactored OUT of kernel into core extensions | pending | kernel names no feature; ext-xdg-shell + ext-layer-shell pass suite |
| 5 | Input routing + ergonomics contract: unified pointer/touch→RMLUi events, keybinding filter chain, touch-mode RCSS variables | pending | same ui surface usable by mouse and finger |
| 6 | First standard extensions: ext-taskbar + ext-launcher | pending | proves the ui-substrate contract is complete (friction = bad contract) |