diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 20:34:03 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 20:34:03 +0900 |
| commit | a112b41d51ef8b114bbbbebb59eab1972750a23c (patch) | |
| tree | 0d221f8913da50cb2609ef2961f9cb9e878b0615 | |
| parent | 8d7749516d70b8a27df4441c2b3e717de1a7a724 (diff) | |
| download | unbox-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.md | 2 | ||||
| -rw-r--r-- | packages/host-bin/src/main.cpp | 6 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/server.hpp | 20 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/wlr.hpp | 13 | ||||
| -rw-r--r-- | packages/kernel/kernel.md | 79 | ||||
| -rw-r--r-- | packages/kernel/meson.build | 15 | ||||
| -rw-r--r-- | packages/kernel/src/input.cpp | 29 | ||||
| -rw-r--r-- | packages/kernel/src/rmlui_renderer_gl3.cpp | 2197 | ||||
| -rw-r--r-- | packages/kernel/src/rmlui_renderer_gl3.h | 227 | ||||
| -rw-r--r-- | packages/kernel/src/server.cpp | 40 | ||||
| -rw-r--r-- | packages/kernel/src/server_impl.hpp | 8 | ||||
| -rw-r--r-- | packages/kernel/src/ui_spike.cpp | 688 | ||||
| -rw-r--r-- | packages/kernel/src/ui_spike.hpp | 77 | ||||
| -rw-r--r-- | packages/kernel/tests/test_kernel.cpp | 72 | ||||
| -rw-r--r-- | tasks.md | 9 |
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"); +} @@ -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) | |
