From 5ca712ee16f1df77e4f927ed5cd7cea007efec19 Mon Sep 17 00:00:00 2001 From: monofuel Date: Wed, 21 Oct 2026 23:26:57 -0400 Subject: [PATCH 01/21] Add experimental Linux direct display and touch backends --- Makefile | 7 + README.md | 5 +- docs/linux-direct.md | 68 +++ src/windy/common.nim | 10 + src/windy/platforms/emscripten/platform.nim | 4 + src/windy/platforms/linux/backends.nim | 19 + src/windy/platforms/linux/direct.nim | 162 ++++++ src/windy/platforms/linux/native/drm.nim | 468 ++++++++++++++++++ .../platforms/linux/native/framebuffer.nim | 148 ++++++ src/windy/platforms/linux/native/pixels.nim | 12 + src/windy/platforms/linux/native/state.nim | 15 + src/windy/platforms/linux/native/touch.nim | 190 +++++++ src/windy/platforms/linux/platform.nim | 422 +++++++++++++++- src/windy/platforms/linux/x11.nim | 100 +++- src/windy/platforms/macos/platform.nim | 4 + src/windy/platforms/win32/platform.nim | 4 + tests/bench_native_upload.nim | 18 + tests/config.nims | 12 + tests/manual_direct_displays.nim | 36 ++ tests/test_linux_display.nim | 52 ++ tests/test_linux_glx.nim | 13 + tests/test_native_input.nim | 59 +++ 22 files changed, 1805 insertions(+), 23 deletions(-) create mode 100644 Makefile create mode 100644 docs/linux-direct.md create mode 100644 src/windy/platforms/linux/backends.nim create mode 100644 src/windy/platforms/linux/direct.nim create mode 100644 src/windy/platforms/linux/native/drm.nim create mode 100644 src/windy/platforms/linux/native/framebuffer.nim create mode 100644 src/windy/platforms/linux/native/pixels.nim create mode 100644 src/windy/platforms/linux/native/state.nim create mode 100644 src/windy/platforms/linux/native/touch.nim create mode 100644 tests/bench_native_upload.nim create mode 100644 tests/manual_direct_displays.nim create mode 100644 tests/test_linux_display.nim create mode 100644 tests/test_linux_glx.nim create mode 100644 tests/test_native_input.nim diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..39570ac5 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: test-native test-linux-cpu test-linux-combined +test-native: + nim r tests/test_native_input.nim +test-linux-cpu: + WINDY_TEST_BACKENDS=x11 nim r tests/test_linux_display.nim +test-linux-combined: + WINDY_TEST_BACKENDS=x11,fbdev,drm nim r tests/test_linux_display.nim diff --git a/README.md b/README.md index f7c4d721..37809bc9 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,13 @@ while not window.closeRequested: ## CPU Pixels -On Windows, compile with `-d:useCpu` to create a plain window without an +On Windows and X11 Linux, compile with `-d:useCpu` to create a plain window without an OpenGL context. CPU renderers can update the client area by passing a Pixie image to `window.presentPixels(image)`. +Linux also has experimental [fbdev, DRM and evdev touch backends](docs/linux-direct.md) +for applications running without a window server. + ### Why not just use GLFW or SDL? diff --git a/docs/linux-direct.md b/docs/linux-direct.md new file mode 100644 index 00000000..779dd1f0 --- /dev/null +++ b/docs/linux-direct.md @@ -0,0 +1,68 @@ +# Direct Linux displays (experimental) + +These CPU pixel backends support Linux systems without X11. The default build +still uses X11/OpenGL. Put the desired settings in your application's +`config.nims`: + +```nim +switch("define", "useCpu") +switch("define", "windyBackends=fbdev,drm") +``` + +`windyBackends` accepts `x11`, `fbdev`, `drm`, or a comma-separated combination. +Direct-only builds do not need X11 libraries. DRM loads `libdrm.so.2` when opened +and currently requires a 64-bit target. Combined builds include X11's library +dependencies even when selecting a direct display. + +Select the backend before creating windows: + +```nim +import pixie, windy + +setLinuxBackend(DrmBackend) +let window = newWindow("Panel", ivec2(0, 0), vsync = false, + linuxOptions = LinuxWindowOptions( + device: "/dev/dri/card0", + output: "DSI-1", + touchDevice: "/dev/input/event1")) +let image = newImage(window.framebufferSize.x, window.framebufferSize.y) +image.fill(color(0.2, 0.4, 0.8, 1)) +window.presentPixels(image) +while not window.closeRequested: + pollEvents() +window.close() +``` + +Choose connector and input paths for your system. Multiple DRM windows can own +separate connectors on the same device. For fbdev, select `FbdevBackend` and +`LinuxWindowOptions(device: "/dev/fb0")`; only one window can own that framebuffer. +Windows use the panel's native size. `rotation` and `touchRotation` accept +0, 90, 180 or 270 degrees. Device discovery and board-specific configuration +belong to the application. + +`window.onTouch` receives begin/move/end/cancel events with stable contact IDs +and window-local pixel positions. `window.touches` contains active contacts. +Evdev single-touch and slot-based multitouch are supported, without smoothing, +gestures or mouse synthesis. X11, browser, Windows and macOS touch delivery is +not implemented in this draft. + +The application needs permission to open its display and input devices, and +exclusive display ownership. Close windows before switching backends. Native +desktop operations such as resizing, clipboard access or OpenGL contexts raise +`WindyError`. Keyboard, mouse and controller input are not supplied by these +direct backends; use the application's input library for controllers. + +Presentation copies into scanout memory. There is no page-flip queue or vblank +pacing, so `vsync` must be false and tearing is possible. Normal close restores +the saved display state when it is still available. If the kernel retired the +previous DRM framebuffer, the output is disabled instead. + +Run `nim r tests/test_native_input.nim` for touch lifecycle and pixel tests. +`WINDY_TEST_BACKENDS=x11,fbdev,drm nim r tests/test_linux_display.nim` checks +combined backend selection and X11 CPU pixels; use an isolated Xvfb display. +`nim r tests/test_linux_glx.nim` checks the default OpenGL path. + +On a dedicated test device, compile `tests/manual_direct_displays.nim` with +`WINDY_TEST_BACKENDS=fbdev,drm` and run it with `fbdev`, or with +`drm CONNECTOR1 CONNECTOR2`. This temporarily changes both outputs and checks +independent ownership and cleanup; stop other display applications first. diff --git a/src/windy/common.nim b/src/windy/common.nim index 5d71a502..6d4790e8 100644 --- a/src/windy/common.nim +++ b/src/windy/common.nim @@ -3,6 +3,16 @@ import pixie, std/strutils, std/unicode type WindyError* = object of ValueError + TouchPhase* = enum + TouchBegin, TouchMove, TouchEnd, TouchCancel + TouchContact* = object + id*: int32 + position*: Vec2 + TouchEvent* = object + contact*: TouchContact + phase*: TouchPhase + TouchCallback* = proc(event: TouchEvent) + OpenGLVersion* = enum OpenGL3Dot0 OpenGL3Dot1 diff --git a/src/windy/platforms/emscripten/platform.nim b/src/windy/platforms/emscripten/platform.nim index cfd36f35..857479a5 100644 --- a/src/windy/platforms/emscripten/platform.nim +++ b/src/windy/platforms/emscripten/platform.nim @@ -13,6 +13,7 @@ type Window* = ref object onCloseRequest*: Callback onFrame*: Callback + onTouch*: TouchCallback onMove*: Callback onResize*: Callback onFocusChange*: Callback @@ -1245,3 +1246,6 @@ proc saveFileDialog*( discard defaultName warn("saveFileDialog is not supported on emscripten") result = "" + +proc touches*(window: Window): seq[TouchContact] = + @[] diff --git a/src/windy/platforms/linux/backends.nim b/src/windy/platforms/linux/backends.nim new file mode 100644 index 00000000..70f13de1 --- /dev/null +++ b/src/windy/platforms/linux/backends.nim @@ -0,0 +1,19 @@ +import std/strutils + +const + windyBackends* {.strdefine.} = "x11" + HasX11* = "x11" in windyBackends.split(',') + HasFbdev* = "fbdev" in windyBackends.split(',') + HasDrm* = "drm" in windyBackends.split(',') + HasDirect* = HasFbdev or HasDrm + +type + LinuxBackend* = enum + X11Backend, FbdevBackend, DrmBackend + LinuxWindowOptions* = object + device*, output*, touchDevice*: string + rotation*, touchRotation*: int + +static: + for backend in windyBackends.split(','): + doAssert backend in ["x11", "fbdev", "drm"], "Unknown Windy backend" diff --git a/src/windy/platforms/linux/direct.nim b/src/windy/platforms/linux/direct.nim new file mode 100644 index 00000000..24eef9bd --- /dev/null +++ b/src/windy/platforms/linux/direct.nim @@ -0,0 +1,162 @@ +import + pixie, + ../../common, + ./backends, + ./native/[state, drm, framebuffer, touch] + +when HasX11: + import ./x11 + type Window* = x11.Window +else: + type Window* = ref object + onCloseRequest*, onFrame*, onMove*, onResize*, onFocusChange*: Callback + onMouseMove*, onScroll*, onImeChange*: Callback + onButtonPress*, onButtonRelease*: ButtonCallback + onRune*: RuneCallback + onFileDrop*: FileDropCallback + onTouch*: TouchCallback + direct*: DirectWindowState + +var windows: seq[Window] + +proc close*(window: Window) = + if window.direct == nil or window.direct.closed: + return + let data = window.direct + data.closed = true + try: + if data.touch != nil: + try: + for event in data.touch.cancel(): + data.contacts.setLen(0) + if window.onTouch != nil: window.onTouch(event) + finally: + data.touch.close() + finally: + try: + if data.fb != nil: data.fb.close() + if data.drm != nil and data.screen != nil: + data.drm.closeScreen(data.screen) + finally: + if data.drm != nil: + for index, screen in data.drm.screens: + if screen == data.screen: + data.drm.screens.delete(index) + break + if data.drm.screens.len == 0: + data.drm.close() + for index, current in windows: + if current == window: + windows.delete(index) + break + +proc newDirectWindow*(title: string, backend: LinuxBackend, + options: LinuxWindowOptions): Window = + let path = if options.device.len > 0: options.device + elif backend == FbdevBackend: "/dev/fb0" else: "/dev/dri/card0" + result = Window(direct: DirectWindowState(title: title, device: path, backend: backend)) + let data = result.direct + try: + if backend == FbdevBackend: + for window in windows: + if window.direct.device == path: + raise newException(WindyError, "Framebuffer is already owned: " & path) + data.fb = openFramebuffer(path, options.rotation) + else: + if options.output.len == 0: + raise newException(WindyError, "A DRM connector must be selected") + for window in windows: + if window.direct.backend == DrmBackend and window.direct.device == path: + data.drm = window.direct.drm + break + if data.drm == nil: + data.drm = openDrm(path) + data.drm.openOutput(@[options.output], options.rotation) + data.screen = data.drm.screens[^1] + if options.touchDevice.len > 0: + for window in windows: + if window.direct.touch != nil and window.direct.touch.path == options.touchDevice: + raise newException(WindyError, "Touchscreen is already assigned") + let + width = if data.fb != nil: data.fb.width else: data.screen.width + height = if data.fb != nil: data.fb.height else: data.screen.height + data.touch = openTouch(options.touchDevice, width, height, options.touchRotation) + windows.add result + except: + result.close() + raise + +proc size*(window: Window): IVec2 = + let data = window.direct + if data.fb != nil: ivec2(data.fb.width.int32, data.fb.height.int32) + else: ivec2(data.screen.width.int32, data.screen.height.int32) + +proc framebufferSize*(window: Window): IVec2 = window.size +proc closed*(window: Window): bool = window.direct.closed +proc closeRequested*(window: Window): bool = window.direct.closeRequested +proc `closeRequested=`*(window: Window, value: bool) = window.direct.closeRequested = value +proc title*(window: Window): string = window.direct.title +proc `title=`*(window: Window, value: string) = window.direct.title = value +proc contentScale*(window: Window): float32 = 1 +proc fullscreen*(window: Window): bool = true +proc visible*(window: Window): bool = not window.closed +proc focused*(window: Window): bool = not window.closed +proc pos*(window: Window): IVec2 = ivec2(0, 0) +proc touches*(window: Window): seq[TouchContact] = window.direct.contacts +proc buttonDown*(window: Window): ButtonView = ButtonView({}) +proc buttonPressed*(window: Window): ButtonView = ButtonView({}) +proc buttonReleased*(window: Window): ButtonView = ButtonView({}) + +proc presentPixels*(window: Window, image: Image) = + if window.closed: + raise newException(WindyError, "Cannot present to a closed window") + let data = window.direct + if data.fb != nil: + data.fb.present(image) + else: + for index, screen in data.drm.screens: + if screen == data.screen: + data.drm.present(index, image) + return + +proc pollEvents*() = + let current = windows + for window in current: + if window.closed: continue + let data = window.direct + if data.touch != nil: + for event in data.touch.poll(): + var found = -1 + for index, contact in data.contacts: + if contact.id == event.contact.id: found = index + if event.phase in {TouchEnd, TouchCancel}: + if found >= 0: data.contacts.delete(found) + elif found >= 0: + data.contacts[found] = event.contact + else: + data.contacts.add event.contact + if window.onTouch != nil: window.onTouch(event) + if window.closed: break + if not window.closed and window.onFrame != nil: + window.onFrame() + +proc hasWindows*(): bool = windows.len > 0 + +proc unsupported*() = + raise newException(WindyError, "Operation is unavailable on a direct display") + +proc minimized*(window: Window): bool = unsupported() +proc maximized*(window: Window): bool = unsupported() +proc style*(window: Window): WindowStyle = unsupported() +proc runeInputEnabled*(window: Window): bool = unsupported() +proc cursor*(window: Window): Cursor = unsupported() +proc mousePos*(window: Window): IVec2 = unsupported() +proc mousePrevPos*(window: Window): IVec2 = unsupported() +proc mouseDelta*(window: Window): IVec2 = unsupported() +proc mouseInside*(window: Window): bool = unsupported() +proc mouseCaptured*(window: Window): bool = unsupported() +proc scrollDelta*(window: Window): Vec2 = unsupported() +proc buttonToggle*(window: Window): ButtonView = unsupported() +proc imeCursorIndex*(window: Window): int = unsupported() +proc imeCompositionString*(window: Window): string = unsupported() +proc url*(window: Window): string = unsupported() diff --git a/src/windy/platforms/linux/native/drm.nim b/src/windy/platforms/linux/native/drm.nim new file mode 100644 index 00000000..15fe4257 --- /dev/null +++ b/src/windy/platforms/linux/native/drm.nim @@ -0,0 +1,468 @@ +import + std/[dynlib, os, posix, strformat, strutils], + pixie, + ./[framebuffer, pixels] + +type + ModeInfo = object + clock: uint32 + hdisplay, hsyncStart, hsyncEnd, htotal, hskew: uint16 + vdisplay, vsyncStart, vsyncEnd, vtotal, vscan: uint16 + refresh, flags, modeType: uint32 + name: array[32, char] + Resources = object + countFbs: cint + fbs: ptr UncheckedArray[uint32] + countCrtcs: cint + crtcs: ptr UncheckedArray[uint32] + countConnectors: cint + connectors: ptr UncheckedArray[uint32] + countEncoders: cint + encoders: ptr UncheckedArray[uint32] + minWidth, maxWidth, minHeight, maxHeight: uint32 + Connector = object + id, encoderId, connectorType, typeId: uint32 + connection: cint + mmWidth, mmHeight: uint32 + subpixel, countModes: cint + modes: ptr UncheckedArray[ModeInfo] + countProperties: cint + properties: ptr UncheckedArray[uint32] + values: ptr UncheckedArray[uint64] + countEncoders: cint + encoders: ptr UncheckedArray[uint32] + Encoder = object + id, encoderType, crtcId, possibleCrtcs, possibleClones: uint32 + Crtc = object + id, bufferId, x, y, width, height: uint32 + modeValid: cint + mode: ModeInfo + gammaSize: cint + DumbBuffer = object + height, width, bpp, flags, handle, pitch: uint32 + size: uint64 + MapBuffer = object + handle, padding: uint32 + offset: uint64 + FramebufferInfo = object + id, width, height, pitch, bpp, depth, handle: uint32 + GemClose = object + handle, padding: uint32 + PlaneResources = object + count: uint32 + planes: ptr UncheckedArray[uint32] + Plane = object + countFormats: uint32 + formats: ptr UncheckedArray[uint32] + id, crtc, framebuffer, crtcX, crtcY, x, y, possibleCrtcs, gammaSize: uint32 + ObjectProperties = object + count: uint32 + ids: ptr UncheckedArray[uint32] + values: ptr UncheckedArray[uint64] + Property = object + id, flags: uint32 + name: array[32, char] + countValues: cint + values: pointer + countEnums: cint + enums: pointer + countBlobs: cint + blobs: pointer + DrmApi = object + library: LibHandle + getResources: proc(fd: cint): ptr Resources {.cdecl.} + freeResources: proc(value: ptr Resources) {.cdecl.} + getConnector: proc(fd: cint, id: uint32): ptr Connector {.cdecl.} + freeConnector: proc(value: ptr Connector) {.cdecl.} + getEncoder: proc(fd: cint, id: uint32): ptr Encoder {.cdecl.} + freeEncoder: proc(value: ptr Encoder) {.cdecl.} + getCrtc: proc(fd: cint, id: uint32): ptr Crtc {.cdecl.} + freeCrtc: proc(value: ptr Crtc) {.cdecl.} + setCrtc: proc(fd: cint, id, buffer, x, y: uint32, connectors: ptr uint32, + count: cint, mode: ptr ModeInfo): cint {.cdecl.} + addFb: proc(fd: cint, width, height: uint32, depth, bpp: uint8, + pitch, handle: uint32, id: ptr uint32): cint {.cdecl.} + removeFb: proc(fd: cint, id: uint32): cint {.cdecl.} + getFb: proc(fd: cint, id: uint32): ptr FramebufferInfo {.cdecl.} + freeFb: proc(value: ptr FramebufferInfo) {.cdecl.} + setMaster, dropMaster: proc(fd: cint): cint {.cdecl.} + setClientCap: proc(fd: cint, capability, value: uint64): cint {.cdecl.} + getPlaneResources: proc(fd: cint): ptr PlaneResources {.cdecl.} + freePlaneResources: proc(value: ptr PlaneResources) {.cdecl.} + getPlane: proc(fd: cint, id: uint32): ptr Plane {.cdecl.} + freePlane: proc(value: ptr Plane) {.cdecl.} + getProperties: proc(fd: cint, id, kind: uint32): ptr ObjectProperties {.cdecl.} + freeProperties: proc(value: ptr ObjectProperties) {.cdecl.} + getProperty: proc(fd: cint, id: uint32): ptr Property {.cdecl.} + freeProperty: proc(value: ptr Property) {.cdecl.} + addFb2: proc(fd: cint, width, height, format: uint32, + handles, pitches, offsets: ptr uint32, id: ptr uint32, flags: uint32): cint {.cdecl.} + DrmScreen* = ref object + name*: string + width*, height*: int + rotation: int + connector, crtc, framebuffer: uint32 + mode: ModeInfo + original: Crtc + originalConnectors: seq[uint32] + buffer: DumbBuffer + memory: pointer + pixels: seq[uint32] + rgba: bool + activated: bool + DrmDisplay* = ref object + api: DrmApi + fd: cint + master: bool + screens*: seq[DrmScreen] + +const + CreateDumb = 0xc02064b2.culong + MapDumb = 0xc01064b3.culong + DestroyDumb = 0xc00464b4.culong + CloseGem = 0x40086409.culong + Connected = 1 + PreferredMode = 1'u32 shl 3 + Xbgr8888 = 0x34324258'u32 + Abgr8888 = 0x34324241'u32 + MaximumBufferBytes = 512 * 1024 * 1024 + ConnectorNames = ["Unknown", "VGA", "DVI-I", "DVI-D", "DVI-A", "Composite", + "SVIDEO", "LVDS", "Component", "DIN", "DP", "HDMI-A", "HDMI-B", "TV", + "eDP", "Virtual", "DSI", "DPI", "Writeback", "SPI", "USB"] + +static: + doAssert sizeof(pointer) == 8 + doAssert sizeof(ModeInfo) == 68 + doAssert sizeof(Resources) == 80 + doAssert sizeof(Connector) == 88 + doAssert sizeof(Crtc) == 100 + doAssert sizeof(Encoder) == 20 + doAssert sizeof(DumbBuffer) == 32 + doAssert sizeof(MapBuffer) == 16 + doAssert sizeof(FramebufferInfo) == 28 + doAssert sizeof(GemClose) == 8 + +proc loadDrm(): DrmApi = + ## Load DRM only when a DRM display is requested. + result.library = loadLib("libdrm.so.2") + if result.library == nil: + raise newException(IOError, "Cannot load libdrm.so.2; install libdrm for DRM displays") + template bindSymbol(field: untyped, name: string) = + result.field = cast[type(result.field)](symAddr(result.library, name)) + if result.field == nil: + unloadLib(result.library) + raise newException(IOError, "Missing DRM symbol: " & name) + bindSymbol(getResources, "drmModeGetResources") + bindSymbol(freeResources, "drmModeFreeResources") + bindSymbol(getConnector, "drmModeGetConnector") + bindSymbol(freeConnector, "drmModeFreeConnector") + bindSymbol(getEncoder, "drmModeGetEncoder") + bindSymbol(freeEncoder, "drmModeFreeEncoder") + bindSymbol(getCrtc, "drmModeGetCrtc") + bindSymbol(freeCrtc, "drmModeFreeCrtc") + bindSymbol(setCrtc, "drmModeSetCrtc") + bindSymbol(addFb, "drmModeAddFB") + bindSymbol(removeFb, "drmModeRmFB") + bindSymbol(getFb, "drmModeGetFB") + bindSymbol(freeFb, "drmModeFreeFB") + bindSymbol(setMaster, "drmSetMaster") + bindSymbol(dropMaster, "drmDropMaster") + bindSymbol(setClientCap, "drmSetClientCap") + bindSymbol(getPlaneResources, "drmModeGetPlaneResources") + bindSymbol(freePlaneResources, "drmModeFreePlaneResources") + bindSymbol(getPlane, "drmModeGetPlane") + bindSymbol(freePlane, "drmModeFreePlane") + bindSymbol(getProperties, "drmModeObjectGetProperties") + bindSymbol(freeProperties, "drmModeFreeObjectProperties") + bindSymbol(getProperty, "drmModeGetProperty") + bindSymbol(freeProperty, "drmModeFreeProperty") + bindSymbol(addFb2, "drmModeAddFB2") + +proc rgbaFormat(display: DrmDisplay, crtcIndex: int): uint32 = + let api = display.api + if api.setClientCap(display.fd, 2, 1) < 0: + if osLastError() == OSErrorCode(EINVAL): return 0 + raiseOSError(osLastError(), "Enabling universal DRM planes") + let resources = api.getPlaneResources(display.fd) + if resources == nil: + raiseOSError(osLastError(), "Reading DRM planes") + defer: api.freePlaneResources(resources) + for index in 0 ..< int(resources.count): + let plane = api.getPlane(display.fd, resources.planes[index]) + if plane == nil: raiseOSError(osLastError(), "Reading DRM plane") + defer: api.freePlane(plane) + if (plane.possibleCrtcs and (1'u32 shl crtcIndex)) == 0: continue + let properties = api.getProperties(display.fd, plane.id, 0xeeeeeeee'u32) + if properties == nil: raiseOSError(osLastError(), "Reading DRM plane properties") + defer: api.freeProperties(properties) + var primary = false + for propertyIndex in 0 ..< int(properties.count): + let property = api.getProperty(display.fd, properties.ids[propertyIndex]) + if property == nil: raiseOSError(osLastError(), "Reading DRM property") + if $cast[cstring](addr property.name[0]) == "type": + primary = properties.values[propertyIndex] == 1 + api.freeProperty(property) + if primary: + for format in [Xbgr8888, Abgr8888]: + for formatIndex in 0 ..< int(plane.countFormats): + if plane.formats[formatIndex] == format: return format + +proc closeScreen*(display: DrmDisplay, screen: DrmScreen) = + var errors: seq[string] + if screen.activated: + var + original = screen.original + connectors = screen.originalConnectors + enabled = original.modeValid != 0 and original.bufferId != 0 + if enabled: + let framebuffer = display.api.getFb(display.fd, original.bufferId) + if framebuffer == nil: + let error = osLastError() + enabled = false + if error == OSErrorCode(ENOENT): + echo "Original framebuffer retired by kernel; disabling ", screen.name, " on exit" + else: + errors.add "Querying original framebuffer for " & screen.name & ": " & $error + else: + if framebuffer.handle != 0: + var handle = GemClose(handle: framebuffer.handle) + if deviceIoctl(display.fd, CloseGem, addr handle) < 0: + errors.add "Closing original GEM handle for " & screen.name + display.api.freeFb(framebuffer) + let + connectorPointer = if enabled and connectors.len > 0: addr connectors[0] else: nil + modePointer = if enabled: addr original.mode else: nil + count = if enabled: cint(connectors.len) else: 0.cint + buffer = if enabled: original.bufferId else: 0'u32 + if display.api.setCrtc(display.fd, screen.crtc, buffer, original.x, + original.y, connectorPointer, count, modePointer) < 0: + errors.add "Restoring " & screen.name + screen.activated = false + if screen.memory != nil: + if munmap(screen.memory, int(screen.buffer.size)) < 0: + errors.add "Unmapping " & screen.name + screen.memory = nil + if screen.framebuffer != 0: + if display.api.removeFb(display.fd, screen.framebuffer) < 0: + errors.add "Removing framebuffer for " & screen.name + screen.framebuffer = 0 + if screen.buffer.handle != 0: + if deviceIoctl(display.fd, DestroyDumb, addr screen.buffer.handle) < 0: + errors.add "Destroying buffer for " & screen.name + screen.buffer.handle = 0 + + if errors.len > 0: + raise newException(IOError, errors.join("; ")) + +proc close*(display: DrmDisplay) = + var errors: seq[string] + for screen in display.screens: + try: + display.closeScreen(screen) + except IOError as error: + errors.add error.msg + if display.fd >= 0: + if display.master and display.api.dropMaster(display.fd) < 0: + errors.add "Releasing DRM master" + if posix.close(display.fd) < 0: + errors.add "Closing DRM device" + display.fd = -1 + if display.api.library != nil: + unloadLib(display.api.library) + display.api.library = nil + if errors.len > 0: + raise newException(IOError, errors.join("; ")) + +proc connectorName(connector: ptr Connector): string = + ## Format the kernel connector type and instance as a stable output name. + let prefix = if connector.connectorType < uint32(ConnectorNames.len): + ConnectorNames[connector.connectorType] else: "Connector" & $connector.connectorType + prefix & "-" & $connector.typeId + +proc chooseCrtc*(ids: openArray[uint32], current, possible: uint32, + unavailable: openArray[uint32]): uint32 = + ## Prefer the current compatible controller without taking another output's controller. + for index, id in ids: + if index < 32 and (possible and (1'u32 shl index)) != 0 and + id notin unavailable and id == current: + return id + for index, id in ids: + if index < 32 and (possible and (1'u32 shl index)) != 0 and id notin unavailable: + return id + raise newException(IOError, "No independent display controller is available") + +proc openOutput*(result: DrmDisplay, outputs: seq[string], rotation: int) = + ## Prepare independent CPU scanout buffers without changing the active display. + if rotation notin [0, 90, 180, 270]: + raise newException(ValueError, "Rotation must be 0, 90, 180 or 270") + let originalCount = result.screens.len + try: + let + api = result.api + resources = api.getResources(result.fd) + if resources == nil: + raiseOSError(osLastError(), "Reading DRM resources") + defer: api.freeResources(resources) + var + connectors: seq[ptr Connector] + names: seq[string] + crtcs: seq[uint32] + used: seq[uint32] + requested = outputs + defer: + for connector in connectors: + api.freeConnector(connector) + for screen in result.screens: + used.add screen.crtc + for index in 0 ..< int(resources.countCrtcs): + crtcs.add resources.crtcs[index] + for index in 0 ..< int(resources.countConnectors): + let connector = api.getConnector(result.fd, resources.connectors[index]) + if connector == nil: + raiseOSError(osLastError(), "Reading DRM connector") + connectors.add connector + names.add connector.connectorName() + if outputs.len == 0 and connector.connection == Connected and connector.countModes > 0: + requested.add names[^1] + if requested.len == 0: + raise newException(IOError, "No connected DRM displays") + for name in requested: + for screen in result.screens: + if screen.name == name: + raise newException(IOError, "DRM output is already owned: " & name) + let index = names.find(name) + if index < 0: + raise newException(IOError, "DRM output not found: " & name) + let connector = connectors[index] + if connector.connection != Connected or connector.countModes == 0: + raise newException(IOError, "DRM output has no connected mode: " & name) + var + possible = 0'u32 + current = 0'u32 + for index in 0 ..< int(connector.countEncoders): + let encoder = api.getEncoder(result.fd, connector.encoders[index]) + if encoder == nil: + raiseOSError(osLastError(), "Reading DRM encoder") + possible = possible or encoder.possibleCrtcs + if encoder.id == connector.encoderId: + current = encoder.crtcId + api.freeEncoder(encoder) + var unavailable = used + for other in connectors: + if other.connectorName() notin requested and other.encoderId != 0: + let encoder = api.getEncoder(result.fd, other.encoderId) + if encoder == nil: + raiseOSError(osLastError(), "Reading existing DRM route") + if encoder.crtcId != 0: + unavailable.add encoder.crtcId + api.freeEncoder(encoder) + let + id = chooseCrtc(crtcs, current, possible, unavailable) + original = api.getCrtc(result.fd, id) + if original == nil: + raiseOSError(osLastError(), "Saving DRM scanout") + let screen = DrmScreen(name: name, connector: connector.id, crtc: id, + original: original[], mode: connector.modes[0], rotation: rotation) + api.freeCrtc(original) + result.screens.add screen + used.add id + for modeIndex in 0 ..< int(connector.countModes): + if (connector.modes[modeIndex].modeType and PreferredMode) != 0: + screen.mode = connector.modes[modeIndex] + break + if screen.original.modeValid != 0: + screen.mode = screen.original.mode + for other in connectors: + if other.encoderId != 0: + let encoder = api.getEncoder(result.fd, other.encoderId) + if encoder == nil: + raiseOSError(osLastError(), "Saving DRM connector routing") + if encoder.crtcId == id: + screen.originalConnectors.add other.id + api.freeEncoder(encoder) + screen.width = int(if rotation in [90, 270]: screen.mode.vdisplay else: screen.mode.hdisplay) + screen.height = int(if rotation in [90, 270]: screen.mode.hdisplay else: screen.mode.vdisplay) + screen.buffer = DumbBuffer(width: uint32(screen.mode.hdisplay), + height: uint32(screen.mode.vdisplay), bpp: 32) + if deviceIoctl(result.fd, CreateDumb, addr screen.buffer) < 0: + raiseOSError(osLastError(), "Allocating DRM scanout buffer") + if screen.buffer.pitch < screen.buffer.width * 4 or + screen.buffer.size < uint64(screen.buffer.pitch) * screen.buffer.height or + screen.buffer.size > MaximumBufferBytes: + raise newException(IOError, "Unsupported DRM buffer geometry") + let format = result.rgbaFormat(crtcs.find(id)) + screen.rgba = format != 0 + if screen.rgba: + var + handles = [screen.buffer.handle, 0'u32, 0, 0] + pitches = [screen.buffer.pitch, 0'u32, 0, 0] + offsets: array[4, uint32] + if api.addFb2(result.fd, screen.buffer.width, screen.buffer.height, format, + addr handles[0], addr pitches[0], addr offsets[0], addr screen.framebuffer, 0) < 0: + raiseOSError(osLastError(), "Registering RGBA DRM framebuffer") + elif api.addFb(result.fd, screen.buffer.width, screen.buffer.height, 24, 32, + screen.buffer.pitch, screen.buffer.handle, addr screen.framebuffer) < 0: + raiseOSError(osLastError(), "Registering DRM framebuffer") + var mapping = MapBuffer(handle: screen.buffer.handle) + if deviceIoctl(result.fd, MapDumb, addr mapping) < 0: + raiseOSError(osLastError(), "Mapping DRM buffer") + screen.memory = mmap(nil, int(screen.buffer.size), PROT_READ or PROT_WRITE, + MAP_SHARED, result.fd, Off(mapping.offset)) + if screen.memory == MAP_FAILED: + screen.memory = nil + raiseOSError(osLastError(), "Mapping DRM scanout memory") + except: + while result.screens.len > originalCount: + let screen = result.screens.pop() + result.closeScreen(screen) + raise + +proc upload*(display: DrmDisplay, index: int, image: Image) = + ## Copy pixels into scanout memory without changing the active controller. + let screen = display.screens[index] + if image.width != screen.width or image.height != screen.height: + raise newException(ValueError, "Canvas does not match DRM screen") + let destination = cast[ptr UncheckedArray[uint32]](screen.memory) + if screen.rgba and screen.rotation == 0: + for row in 0 ..< image.height: + copyMem(addr destination[row * int(screen.buffer.pitch) div 4], + unsafeAddr image.data[row * image.width], image.width * 4) + return + screen.pixels.setLen(image.data.len) + if screen.rotation == 0: + for index, pixel in image.data: + screen.pixels[index] = rgb(pixel) + else: + for y in 0 ..< image.height: + for x in 0 ..< image.width: + let point = rotatedPoint(x, y, image.width, image.height, screen.rotation) + screen.pixels[point.y * int(screen.buffer.width) + point.x] = + (if screen.rgba: cast[uint32](image.data[y * image.width + x]) + else: rgb(image.data[y * image.width + x])) + for row in 0 ..< int(screen.buffer.height): + copyMem(addr destination[row * int(screen.buffer.pitch) div 4], + addr screen.pixels[row * int(screen.buffer.width)], int(screen.buffer.width) * 4) + +proc present*(display: DrmDisplay, index: int, image: Image) = + display.upload(index, image) + let screen = display.screens[index] + if not screen.activated: + if not display.master: + if display.api.setMaster(display.fd) < 0: + raiseOSError(osLastError(), "Acquiring DRM master; another display application may be running") + display.master = true + if display.api.setCrtc(display.fd, screen.crtc, screen.framebuffer, 0, 0, + addr screen.connector, 1, addr screen.mode) < 0: + raiseOSError(osLastError(), "Displaying " & screen.name) + screen.activated = true + +proc describe*(screen: DrmScreen): string = + ## Describe the selected connector, logical geometry and controller. + &"{screen.name}: {screen.width}x{screen.height}, CRTC {screen.crtc}, rotation {screen.rotation}" +proc openDrm*(path: string): DrmDisplay = + result = DrmDisplay(fd: -1, api: loadDrm()) + result.fd = posix.open(path.cstring, O_RDWR or O_CLOEXEC) + if result.fd < 0: + let error = osLastError() + result.close() + raiseOSError(error, path) diff --git a/src/windy/platforms/linux/native/framebuffer.nim b/src/windy/platforms/linux/native/framebuffer.nim new file mode 100644 index 00000000..56f8afb2 --- /dev/null +++ b/src/windy/platforms/linux/native/framebuffer.nim @@ -0,0 +1,148 @@ +import + std/[os, posix, strformat], + pixie, + ./pixels + +type + Bitfield* {.importc: "struct fb_bitfield", header: "", bycopy.} = object + offset*, length*, msb_right*: uint32 + VariableInfo {.importc: "struct fb_var_screeninfo", header: "", bycopy.} = object + xres, yres, xres_virtual, yres_virtual, xoffset, yoffset: uint32 + bits_per_pixel, grayscale: uint32 + red, green, blue, transp: Bitfield + nonstd, activate, height, width, accel_flags: uint32 + pixclock, left_margin, right_margin, upper_margin, lower_margin: uint32 + hsync_len, vsync_len, sync, vmode, rotate, colorspace: uint32 + reserved: array[4, uint32] + FixedInfo {.importc: "struct fb_fix_screeninfo", header: "", bycopy.} = object + id: array[16, char] + smem_start: culong + smem_len, `type`, type_aux, visual: uint32 + xpanstep, ypanstep, ywrapstep: uint16 + line_length: uint32 + mmio_start: culong + mmio_len, accel: uint32 + capabilities: uint16 + reserved: array[2, uint16] + Framebuffer* = ref object + fd: cint + variable, original: VariableInfo + fixed: FixedInfo + memory: pointer + saved: seq[byte] + length: int + activated, touched: bool + rotation*: int + width*, height*: int + +const + GetVariable = 0x4600.culong + PutVariable = 0x4601.culong + GetFixed = 0x4602.culong + PanDisplay = 0x4606.culong + Blank = 0x4611.culong + ActivateForce = 128'u32 + +proc deviceIoctl*(fd: cint, request: culong, argument: pointer): cint {.importc: "ioctl", header: "".} + +proc packPixel*(color: uint32, red, green, blue, alpha: Bitfield): uint32 = + for (value, channel) in [(color shr 16 and 255, red), + (color shr 8 and 255, green), (color and 255, blue), (255'u32, alpha)]: + if channel.length > 0: + if channel.length > 8 or channel.offset + channel.length > 32 or channel.msb_right != 0: + raise newException(ValueError, "Unsupported framebuffer channel layout") + let maximum = (1'u32 shl channel.length) - 1 + result = result or ((value * maximum + 127) div 255 shl channel.offset) + +proc checkedIoctl(fd: cint, request: culong, argument: pointer) = + if deviceIoctl(fd, request, argument) < 0: + raiseOSError(osLastError()) + +proc openFramebuffer*(path: string, rotation = 0): Framebuffer = + if rotation notin [0, 90, 180, 270]: + raise newException(ValueError, "Rotation must be 0, 90, 180 or 270") + result = Framebuffer(fd: -1, rotation: rotation) + result.fd = posix.open(path.cstring, O_RDWR or O_CLOEXEC) + if result.fd < 0: + raiseOSError(osLastError(), path) + try: + checkedIoctl(result.fd, GetVariable, addr result.variable) + checkedIoctl(result.fd, GetFixed, addr result.fixed) + result.original = result.variable + if result.variable.bits_per_pixel notin [16'u32, 24, 32] or + result.variable.grayscale != 0 or result.fixed.`type` != 0 or result.fixed.visual != 2: + raise newException(ValueError, "Expected a packed truecolor framebuffer") + let + width = int(result.variable.xres) + height = int(result.variable.yres) + stride = int(result.fixed.line_length) + pixelBytes = int(result.variable.bits_per_pixel div 8) + if width notin 1..8192 or height notin 1..8192 or + stride < width * pixelBytes or stride > 131072: + raise newException(ValueError, "Invalid framebuffer geometry") + result.length = int(result.fixed.smem_len) + if result.length < stride * height or result.length > 256 * 1024 * 1024: + raise newException(ValueError, "Invalid framebuffer memory size") + discard packPixel(0xffffff'u32, result.variable.red, result.variable.green, + result.variable.blue, result.variable.transp) + result.width = if rotation in [90, 270]: height else: width + result.height = if rotation in [90, 270]: width else: height + result.memory = mmap(nil, result.length, PROT_READ or PROT_WRITE, MAP_SHARED, result.fd, 0) + if result.memory == MAP_FAILED: + result.memory = nil + raiseOSError(osLastError(), "Mapping framebuffer") + result.saved = newSeq[byte](result.length) + copyMem(addr result.saved[0], result.memory, result.length) + except: + if result.memory != nil: + discard munmap(result.memory, result.length) + discard posix.close(result.fd) + raise + +proc describe*(fb: Framebuffer): string = + let v = fb.variable + &"{v.xres}x{v.yres} visible, {v.xres_virtual}x{v.yres_virtual} virtual, " & + &"{v.bits_per_pixel}bpp, stride={fb.fixed.line_length}, offsets={v.xoffset},{v.yoffset}, " & + &"RGB={v.red.offset}:{v.red.length}/{v.green.offset}:{v.green.length}/{v.blue.offset}:{v.blue.length}" + +proc present*(fb: Framebuffer, image: Image) = + if image.width != fb.width or image.height != fb.height: + raise newException(ValueError, "Canvas dimensions differ from framebuffer") + let + destination = cast[ptr UncheckedArray[byte]](fb.memory) + bytesPerPixel = int(fb.variable.bits_per_pixel div 8) + stride = int(fb.fixed.line_length) + fb.touched = true + for y in 0 ..< image.height: + for x in 0 ..< image.width: + let + point = rotatedPoint(x, y, image.width, image.height, fb.rotation) + offset = point.y * stride + point.x * bytesPerPixel + pixel = packPixel(rgb(image.data[y * image.width + x]), fb.variable.red, + fb.variable.green, fb.variable.blue, fb.variable.transp) + for index in 0 ..< bytesPerPixel: + destination[offset + index] = byte((pixel shr (index * 8)) and 255) + if not fb.activated: + fb.variable.xoffset = 0 + fb.variable.yoffset = 0 + if deviceIoctl(fb.fd, PanDisplay, addr fb.variable) < 0: + fb.variable.activate = ActivateForce + checkedIoctl(fb.fd, PutVariable, addr fb.variable) + checkedIoctl(fb.fd, Blank, nil) + fb.activated = true + +proc close*(fb: Framebuffer) = + if fb.memory != nil: + var restoreResult = 0.cint + if fb.touched: + copyMem(fb.memory, addr fb.saved[0], fb.length) + if fb.activated: + restoreResult = deviceIoctl(fb.fd, PanDisplay, addr fb.original) + if restoreResult < 0: + restoreResult = deviceIoctl(fb.fd, PutVariable, addr fb.original) + let unmapResult = munmap(fb.memory, fb.length) + fb.memory = nil + let closeResult = posix.close(fb.fd) + fb.fd = -1 + if restoreResult < 0 or unmapResult < 0 or closeResult < 0: + raise newException(IOError, "Unable to completely restore/close framebuffer") diff --git a/src/windy/platforms/linux/native/pixels.nim b/src/windy/platforms/linux/native/pixels.nim new file mode 100644 index 00000000..85bd384d --- /dev/null +++ b/src/windy/platforms/linux/native/pixels.nim @@ -0,0 +1,12 @@ +import pixie + +proc rgb*(pixel: ColorRGBX): uint32 {.inline.} = + uint32(pixel.r) shl 16 or uint32(pixel.g) shl 8 or uint32(pixel.b) + +proc rotatedPoint*(x, y, width, height, rotation: int): tuple[x, y: int] {.inline.} = + case rotation + of 0: (x, y) + of 90: (height - 1 - y, x) + of 180: (width - 1 - x, height - 1 - y) + of 270: (y, width - 1 - x) + else: raise newException(ValueError, "Rotation must be 0, 90, 180 or 270") diff --git a/src/windy/platforms/linux/native/state.nim b/src/windy/platforms/linux/native/state.nim new file mode 100644 index 00000000..356d7199 --- /dev/null +++ b/src/windy/platforms/linux/native/state.nim @@ -0,0 +1,15 @@ +import + ../../../common, + ../backends, + ./[drm, framebuffer, touch] + +type + DirectWindowState* = ref object + fb*: Framebuffer + drm*: DrmDisplay + screen*: DrmScreen + touch*: TouchReader + device*, title*: string + backend*: LinuxBackend + closed*, closeRequested*: bool + contacts*: seq[TouchContact] diff --git a/src/windy/platforms/linux/native/touch.nim b/src/windy/platforms/linux/native/touch.nim new file mode 100644 index 00000000..6a279974 --- /dev/null +++ b/src/windy/platforms/linux/native/touch.nim @@ -0,0 +1,190 @@ +import + std/[os, posix], + pixie, + ../../../common, + ./[framebuffer, pixels] + +type + Event {.importc: "struct input_event", header: "", bycopy.} = object + time: Timeval + kind {.importc: "type".}: uint16 + code: uint16 + value: int32 + Axis {.importc: "struct input_absinfo", header: "", bycopy.} = object + value, minimum, maximum, fuzz, flat, resolution: int32 + Slot* = object + id*, x*, y*: int32 + TouchReader* = ref object + fd: cint + path*: string + minimumX*, maximumX*, minimumY*, maximumY*: int32 + slotMinimum, slot: int + multitouch, dropped: bool + pending*, previous*: seq[Slot] + width*, height*, rotation*: int + +proc getAxis(code: cint): culong {.importc: "EVIOCGABS", header: "".} +proc getSlots(length: cint): culong {.importc: "EVIOCGMTSLOTS", header: "".} +proc getKeys(length: cint): culong {.importc: "EVIOCGKEY", header: "".} +proc getBits(kind, length: cint): culong {.importc: "EVIOCGBIT", header: "".} + +proc contact(reader: TouchReader, slot: Slot): TouchContact = + let + rawWidth = if reader.rotation in [90, 270]: reader.height else: reader.width + rawHeight = if reader.rotation in [90, 270]: reader.width else: reader.height + x = int((int64(clamp(slot.x, reader.minimumX, reader.maximumX)) - reader.minimumX) * + (rawWidth - 1) div (int64(reader.maximumX) - reader.minimumX)) + y = int((int64(clamp(slot.y, reader.minimumY, reader.maximumY)) - reader.minimumY) * + (rawHeight - 1) div (int64(reader.maximumY) - reader.minimumY)) + point = rotatedPoint(x, y, rawWidth, rawHeight, reader.rotation) + TouchContact(id: slot.id, position: vec2(point.x.float32, point.y.float32)) + +proc report*(reader: TouchReader): seq[TouchEvent] = + ## Publish a coherent report, ending replaced contacts before beginning new ones. + for index, old in reader.previous: + let current = reader.pending[index] + if old.id >= 0 and old.id != current.id: + result.add TouchEvent(contact: reader.contact(old), phase: TouchEnd) + for index, current in reader.pending: + let old = reader.previous[index] + if current.id >= 0: + if old.id != current.id: + result.add TouchEvent(contact: reader.contact(current), phase: TouchBegin) + elif old.x != current.x or old.y != current.y: + result.add TouchEvent(contact: reader.contact(current), phase: TouchMove) + reader.previous = reader.pending + +proc cancel*(reader: TouchReader): seq[TouchEvent] = + for old in reader.previous: + if old.id >= 0: + result.add TouchEvent(contact: reader.contact(old), phase: TouchCancel) + for slot in reader.pending.mitems: + slot.id = -1 + reader.previous = reader.pending + +proc resync(reader: TouchReader) = + if reader.multitouch: + var axis: Axis + if deviceIoctl(reader.fd, getAxis(47), addr axis) < 0: + raiseOSError(osLastError(), reader.path) + reader.slot = int(axis.value) - reader.slotMinimum + for code in [57'i32, 53, 54]: + var values = newSeq[int32](reader.pending.len + 1) + values[0] = code + if deviceIoctl(reader.fd, getSlots(cint(values.len * sizeof(int32))), addr values[0]) < 0: + raiseOSError(osLastError(), reader.path) + for index in 0 ..< reader.pending.len: + case code + of 57: reader.pending[index].id = values[index + 1] + of 53: reader.pending[index].x = values[index + 1] + of 54: reader.pending[index].y = values[index + 1] + else: discard + else: + var + keys: array[96, byte] + x, y: Axis + if deviceIoctl(reader.fd, getKeys(cint(keys.len)), addr keys[0]) < 0 or + deviceIoctl(reader.fd, getAxis(0), addr x) < 0 or + deviceIoctl(reader.fd, getAxis(1), addr y) < 0: + raiseOSError(osLastError(), reader.path) + reader.pending[0] = Slot(id: (if (keys[330 div 8] and (1'u8 shl (330 mod 8))) != 0: 0'i32 else: -1'i32), + x: x.value, y: y.value) + +proc close*(reader: TouchReader) = + if reader.fd >= 0: + let fd = reader.fd + reader.fd = -1 + if posix.close(fd) < 0: + raiseOSError(osLastError(), reader.path) + +proc openTouch*(path: string, width, height, rotation: int): TouchReader = + discard rotatedPoint(0, 0, width, height, rotation) + result = TouchReader(fd: -1, path: path, width: width, height: height, rotation: rotation) + result.fd = posix.open(path.cstring, O_RDONLY or O_NONBLOCK or O_CLOEXEC) + if result.fd < 0: + raiseOSError(osLastError(), path) + try: + var + bits: array[8, byte] + slots, x, y: Axis + if deviceIoctl(result.fd, getBits(3, cint(bits.len)), addr bits[0]) < 0: + raiseOSError(osLastError(), path) + result.multitouch = (bits[47 div 8] and (1'u8 shl (47 mod 8))) != 0 + if result.multitouch: + if deviceIoctl(result.fd, getAxis(47), addr slots) < 0: + raiseOSError(osLastError(), path) + result.slotMinimum = int(slots.minimum) + let + count = if result.multitouch: int(slots.maximum - slots.minimum + 1) else: 1 + xCode = if result.multitouch: 53 else: 0 + yCode = if result.multitouch: 54 else: 1 + if count notin 1 .. 32: + raise newException(WindyError, "Unsupported touch slot count") + if deviceIoctl(result.fd, getAxis(cint(xCode)), addr x) < 0 or + deviceIoctl(result.fd, getAxis(cint(yCode)), addr y) < 0: + raiseOSError(osLastError(), path) + if x.maximum <= x.minimum or y.maximum <= y.minimum: + raise newException(WindyError, "Invalid touchscreen coordinate ranges") + result.minimumX = x.minimum + result.maximumX = x.maximum + result.minimumY = y.minimum + result.maximumY = y.maximum + result.pending = newSeq[Slot](count) + for slot in result.pending.mitems: + slot.id = -1 + result.previous = result.pending + result.resync() + except: + result.close() + raise + +proc poll*(reader: TouchReader): seq[TouchEvent] = + if reader.fd < 0: + return + result = reader.report() + while true: + var event: Event + let count = posix.read(reader.fd, addr event, sizeof(event)) + if count < 0: + let error = osLastError() + if error == OSErrorCode(EAGAIN): break + if error == OSErrorCode(EINTR): continue + if error == OSErrorCode(ENODEV): + result.add reader.cancel() + reader.close() + break + raiseOSError(error, reader.path) + if count == 0: + result.add reader.cancel() + reader.close() + break + if count != sizeof(event): + raise newException(WindyError, "Incomplete touchscreen event") + if event.kind == 0 and event.code == 3: + result.add reader.cancel() + reader.dropped = true + elif reader.dropped: + if event.kind == 0 and event.code == 0: + reader.resync() + reader.dropped = false + result.add reader.report() + elif event.kind == 0 and event.code == 0: + result.add reader.report() + elif event.kind == 3: + if event.code == 47 and reader.multitouch: + let slot = int(event.value) - reader.slotMinimum + if slot notin 0 ..< reader.pending.len: + raise newException(WindyError, "Invalid touchscreen slot") + reader.slot = slot + elif reader.slot in 0 ..< reader.pending.len: + case event.code + of 57: reader.pending[reader.slot].id = event.value + of 53: reader.pending[reader.slot].x = event.value + of 54: reader.pending[reader.slot].y = event.value + of 0: + if not reader.multitouch: reader.pending[0].x = event.value + of 1: + if not reader.multitouch: reader.pending[0].y = event.value + else: discard + elif event.kind == 1 and event.code == 330 and not reader.multitouch: + reader.pending[0].id = if event.value == 0: -1 else: 0 diff --git a/src/windy/platforms/linux/platform.nim b/src/windy/platforms/linux/platform.nim index d581b16d..7a2fbaa1 100644 --- a/src/windy/platforms/linux/platform.nim +++ b/src/windy/platforms/linux/platform.nim @@ -1,5 +1,417 @@ -# when defined(wayland): -# include wayland -# else: -include x11 -# include x11_2 +import ./backends +export backends + +when not HasDirect: + include x11 + + proc setLinuxBackend*(backend: LinuxBackend) = + if backend != X11Backend: + raise newException(WindyError, "Backend was not compiled into Windy") +else: + import pixie, ../../common, ./direct + import ../../http + export http + when HasX11: + import ./x11 + export x11.quitRequested, x11.onQuitRequest, x11.multiClickInterval, x11.multiClickRadius + else: + var + quitRequested*: bool + onQuitRequest*: Callback + type Window* = direct.Window + var selected = when HasX11: X11Backend elif HasFbdev: FbdevBackend else: DrmBackend + + proc setLinuxBackend*(backend: LinuxBackend) = + if backend == selected: return + if direct.hasWindows(): + raise newException(WindyError, "Close windows before changing backend") + when HasX11: + if x11.hasWindows(): + raise newException(WindyError, "Close windows before changing backend") + case backend + of X11Backend: + when not HasX11: raise newException(WindyError, "X11 backend was not compiled") + of FbdevBackend: + when not HasFbdev: raise newException(WindyError, "fbdev backend was not compiled") + of DrmBackend: + when not HasDrm: raise newException(WindyError, "DRM backend was not compiled") + selected = backend + + proc newWindow*(title: string, size: IVec2, style = DecoratedResizable, + visible = true, vsync = true, openglVersion = OpenGL4Dot1, + msaa = msaaDisabled, depthBits = 24, stencilBits = 8, + linuxOptions = LinuxWindowOptions()): Window = + if selected == X11Backend: + when HasX11: + return x11.newWindow(title, size, style, visible, vsync, openglVersion, + msaa, depthBits, stencilBits, linuxOptions) + else: raise newException(WindyError, "X11 backend was not compiled") + if vsync: + raise newException(WindyError, "Direct pixel presentation requires vsync=false") + direct.newDirectWindow(title, selected, linuxOptions) + + proc pollEvents*() = + if selected == X11Backend: + when HasX11: x11.pollEvents() + else: + direct.pollEvents() + pollHttp() + + proc closed*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.closed(window) + direct.closed(window) + + proc size*(window: Window): IVec2 = + when HasX11: + if window.direct == nil: return x11.size(window) + direct.size(window) + + proc framebufferSize*(window: Window): IVec2 = + when HasX11: + if window.direct == nil: return x11.framebufferSize(window) + direct.framebufferSize(window) + + proc title*(window: Window): string = + when HasX11: + if window.direct == nil: return x11.title(window) + direct.title(window) + + proc contentScale*(window: Window): float32 = + when HasX11: + if window.direct == nil: return x11.contentScale(window) + direct.contentScale(window) + + proc fullscreen*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.fullscreen(window) + direct.fullscreen(window) + + proc visible*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.visible(window) + direct.visible(window) + + proc focused*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.focused(window) + direct.focused(window) + + proc pos*(window: Window): IVec2 = + when HasX11: + if window.direct == nil: return x11.pos(window) + direct.pos(window) + + proc touches*(window: Window): seq[TouchContact] = + when HasX11: + if window.direct == nil: return x11.touches(window) + direct.touches(window) + + proc buttonDown*(window: Window): ButtonView = + when HasX11: + if window.direct == nil: return x11.buttonDown(window) + direct.buttonDown(window) + + proc buttonPressed*(window: Window): ButtonView = + when HasX11: + if window.direct == nil: return x11.buttonPressed(window) + direct.buttonPressed(window) + + proc buttonReleased*(window: Window): ButtonView = + when HasX11: + if window.direct == nil: return x11.buttonReleased(window) + direct.buttonReleased(window) + + proc closeRequested*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.closeRequested(window) + direct.closeRequested(window) + + proc close*(window: Window) = + when HasX11: + if window.direct == nil: + x11.close(window) + return + direct.close(window) + + proc `title=`*(window: Window, value: string) = + when HasX11: + if window.direct == nil: + x11.`title=`(window, value) + return + direct.`title=`(window, value) + + proc `closeRequested=`*(window: Window, value: bool) = + when HasX11: + if window.direct == nil: + x11.`closeRequested=`(window, value) + return + direct.`closeRequested=`(window, value) + + when defined(useCpu): + proc presentPixels*(window: Window, image: Image) = + when HasX11: + if window.direct == nil: + x11.presentPixels(window, image) + return + direct.presentPixels(window, image) + + proc minimized*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.minimized(window) + direct.unsupported() + + proc maximized*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.maximized(window) + direct.unsupported() + + proc style*(window: Window): WindowStyle = + when HasX11: + if window.direct == nil: return x11.style(window) + direct.unsupported() + + proc runeInputEnabled*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.runeInputEnabled(window) + direct.unsupported() + + proc cursor*(window: Window): Cursor = + when HasX11: + if window.direct == nil: return x11.cursor(window) + direct.unsupported() + + proc mousePos*(window: Window): IVec2 = + when HasX11: + if window.direct == nil: return x11.mousePos(window) + direct.unsupported() + + proc mousePrevPos*(window: Window): IVec2 = + when HasX11: + if window.direct == nil: return x11.mousePrevPos(window) + direct.unsupported() + + proc mouseDelta*(window: Window): IVec2 = + when HasX11: + if window.direct == nil: return x11.mouseDelta(window) + direct.unsupported() + + proc mouseInside*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.mouseInside(window) + direct.unsupported() + + proc mouseCaptured*(window: Window): bool = + when HasX11: + if window.direct == nil: return x11.mouseCaptured(window) + direct.unsupported() + + proc scrollDelta*(window: Window): Vec2 = + when HasX11: + if window.direct == nil: return x11.scrollDelta(window) + direct.unsupported() + + proc buttonToggle*(window: Window): ButtonView = + when HasX11: + if window.direct == nil: return x11.buttonToggle(window) + direct.unsupported() + + proc imeCursorIndex*(window: Window): int = + when HasX11: + if window.direct == nil: return x11.imeCursorIndex(window) + direct.unsupported() + + proc imeCompositionString*(window: Window): string = + when HasX11: + if window.direct == nil: return x11.imeCompositionString(window) + direct.unsupported() + + proc url*(window: Window): string = + when HasX11: + if window.direct == nil: return x11.url(window) + direct.unsupported() + + proc makeContextCurrent*(window: Window) = + when HasX11: + if window.direct == nil: + x11.makeContextCurrent(window) + return + direct.unsupported() + + proc swapBuffers*(window: Window) = + when HasX11: + if window.direct == nil: + x11.swapBuffers(window) + return + direct.unsupported() + + proc centerWindow*(window: Window) = + when HasX11: + if window.direct == nil: + x11.centerWindow(window) + return + direct.unsupported() + + proc focus*(window: Window) = + when HasX11: + if window.direct == nil: + x11.focus(window) + return + direct.unsupported() + + proc captureMouse*(window: Window) = + when HasX11: + if window.direct == nil: + x11.captureMouse(window) + return + direct.unsupported() + + proc releaseMouse*(window: Window) = + when HasX11: + if window.direct == nil: + x11.releaseMouse(window) + return + direct.unsupported() + + proc closeIme*(window: Window) = + when HasX11: + if window.direct == nil: + x11.closeIme(window) + return + direct.unsupported() + + proc `visible=`*(window: Window, value: bool) = + when HasX11: + if window.direct == nil: + x11.`visible=`(window, value) + return + direct.unsupported() + + proc `pos=`*(window: Window, value: IVec2) = + when HasX11: + if window.direct == nil: + x11.`pos=`(window, value) + return + direct.unsupported() + + proc `size=`*(window: Window, value: IVec2) = + when HasX11: + if window.direct == nil: + x11.`size=`(window, value) + return + direct.unsupported() + + proc `maximized=`*(window: Window, value: bool) = + when HasX11: + if window.direct == nil: + x11.`maximized=`(window, value) + return + direct.unsupported() + + proc `minimized=`*(window: Window, value: bool) = + when HasX11: + if window.direct == nil: + x11.`minimized=`(window, value) + return + direct.unsupported() + + proc `fullscreen=`*(window: Window, value: bool) = + when HasX11: + if window.direct == nil: + x11.`fullscreen=`(window, value) + return + direct.unsupported() + + proc `style=`*(window: Window, value: WindowStyle) = + when HasX11: + if window.direct == nil: + x11.`style=`(window, value) + return + direct.unsupported() + + proc `runeInputEnabled=`*(window: Window, value: bool) = + when HasX11: + if window.direct == nil: + x11.`runeInputEnabled=`(window, value) + return + direct.unsupported() + + proc `cursor=`*(window: Window, value: Cursor) = + when HasX11: + if window.direct == nil: + x11.`cursor=`(window, value) + return + direct.unsupported() + + proc `icon=`*(window: Window, value: Image) = + when HasX11: + if window.direct == nil: + x11.`icon=`(window, value) + return + direct.unsupported() + + proc getClipboardString*(): string = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.getClipboardString() + else: direct.unsupported() + + proc setClipboardString*(value: string) = + if selected != X11Backend: direct.unsupported() + when HasX11: + x11.setClipboardString(value) + else: direct.unsupported() + + proc getClipboardContentKinds*(): set[ClipboardContentKind] = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.getClipboardContentKinds() + else: direct.unsupported() + + proc getClipboardImage*(): Image = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.getClipboardImage() + else: direct.unsupported() + + proc getConfigHome*(appName: string): string = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.getConfigHome(appName) + else: direct.unsupported() + + proc getConfig*(appName, fileName: string): string = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.getConfig(appName, fileName) + else: direct.unsupported() + + proc setConfig*(appName, fileName, content: string) = + if selected != X11Backend: direct.unsupported() + when HasX11: + x11.setConfig(appName, fileName, content) + else: direct.unsupported() + + proc openUrl*(url: string) = + if selected != X11Backend: direct.unsupported() + when HasX11: + x11.openUrl(url) + else: direct.unsupported() + + proc openTempTextFile*(title, text: string) = + if selected != X11Backend: direct.unsupported() + when HasX11: + x11.openTempTextFile(title, text) + else: direct.unsupported() + + proc openFileDialog*(title = "Open File", filters: seq[FileDialogFilter] = @[], defaultPath = ""): string = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.openFileDialog(title, filters, defaultPath) + else: direct.unsupported() + + proc saveFileDialog*(title = "Save File", filters: seq[FileDialogFilter] = @[], defaultPath = "", defaultName = ""): string = + if selected != X11Backend: direct.unsupported() + when HasX11: + return x11.saveFileDialog(title, filters, defaultPath, defaultName) + else: direct.unsupported() diff --git a/src/windy/platforms/linux/x11.nim b/src/windy/platforms/linux/x11.nim index 5b29e3cd..add90913 100644 --- a/src/windy/platforms/linux/x11.nim +++ b/src/windy/platforms/linux/x11.nim @@ -7,12 +7,19 @@ import import ../../http export http +import ./backends +when HasDirect: + import ./native/state + type XWindow = x.Window Window* = ref object + when HasDirect: + direct*: DirectWindowState onCloseRequest*: Callback onFrame*: Callback + onTouch*: TouchCallback onMove*: Callback onResize*: Callback onFocusChange*: Callback @@ -734,7 +741,8 @@ proc newWindow*( openglVersion = OpenGL4Dot1, msaa = msaaDisabled, depthBits = 24, - stencilBits = 8 + stencilBits = 8, + linuxOptions = LinuxWindowOptions() ): Window = ## Creates a new window. Intitializes Windy if needed. init() @@ -802,10 +810,10 @@ proc newWindow*( var gcv: XGCValues result.gc = display.XCreateGC(result.handle, GCForeground or GCBackground, gcv.addr) - result.ctx = display.glXCreateContext(vi.addr, nil, 1) - - if result.ctx == nil: - raise WindyError.newException("Error creating OpenGL context") + when not defined(useCpu): + result.ctx = display.glXCreateContext(vi.addr, nil, 1) + if result.ctx == nil: + raise WindyError.newException("Error creating OpenGL context") result.title = title @@ -817,18 +825,17 @@ proc newWindow*( const xdndVersion = 5'u32 result.handle.setProperty(xaXdndAware, xaCardinal, 32, @[xdndVersion].asString) - makeContextCurrent result - - if vsync: - if glXSwapIntervalEXT != nil: - display.glXSwapIntervalEXT(result.handle, 1) - elif glXSwapIntervalMESA != nil: - glXSwapIntervalMESA(1) - elif glXSwapIntervalSGI != nil: - glXSwapIntervalSGI(1) - else: - raise WindyError.newException("VSync is not supported") - + when not defined(useCpu): + makeContextCurrent result + if vsync: + if glXSwapIntervalEXT != nil: + display.glXSwapIntervalEXT(result.handle, 1) + elif glXSwapIntervalMESA != nil: + glXSwapIntervalMESA(1) + elif glXSwapIntervalSGI != nil: + glXSwapIntervalSGI(1) + else: + raise WindyError.newException("VSync is not supported") if visible: result.visible = true @@ -1455,3 +1462,62 @@ proc saveFileDialog*( args.add("--file-filter=" & filter.name & " | " & filter.extensions.replace(";", " ").replace(",", " ")) result = runZenity(args) + +proc touches*(window: Window): seq[TouchContact] = + @[] +when defined(useCpu): + type CpuImage = object + width, height, xoffset, format: cint + data: cstring + byteOrder, bitmapUnit, bitmapBitOrder, bitmapPad, depth: cint + bytesPerLine, bitsPerPixel: cint + redMask, greenMask, blueMask: culong + obdata: pointer + functions: array[6, pointer] + proc createImage(d: Display, visual: ptr Visual, depth: cuint, format: cint, + offset: cint, data: cstring, width, height: cuint, pad, stride: cint): ptr CpuImage + {.cdecl, importc: "XCreateImage", dynlib: libX11.} + proc putImage(d: Display, window: XWindow, gc: GC, image: ptr CpuImage, + sx, sy, dx, dy: cint, width, height: cuint): cint + {.cdecl, importc: "XPutImage", dynlib: libX11.} + proc putPixel(image: ptr CpuImage, x, y: cint, pixel: culong): cint + {.cdecl, importc: "XPutPixel", dynlib: libX11.} + proc destroyImage(image: ptr CpuImage): cint + {.cdecl, importc: "XDestroyImage", dynlib: libX11.} + proc malloc(size: csize_t): pointer {.importc, header: "".} + + proc channel(value: uint8, mask: culong): culong = + var bits = mask + var shift = 0 + while bits != 0 and (bits and 1) == 0: + bits = bits shr 1 + inc shift + ((culong(value) * bits + 127) div 255 shl shift) and mask + + proc presentPixels*(window: Window, image: Image) = + if window.closed: + raise newException(WindyError, "Cannot present to a closed window") + if image == nil or image.width <= 0 or image.height <= 0: + raise newException(WindyError, "Invalid pixel image") + var attributes: XWindowAttributes + display.XGetWindowAttributes(window.handle, addr attributes) + let native = createImage(display, attributes.visual, cuint(attributes.depth), + 2, 0, nil, cuint(image.width), cuint(image.height), 32, 0) + if native == nil: + raise newException(WindyError, "Cannot create X11 pixel image") + defer: discard destroyImage(native) + native.data = cast[cstring](malloc(csize_t(native.bytesPerLine) * csize_t(image.height))) + if native.data == nil: + raise newException(WindyError, "Cannot allocate X11 pixel image") + for y in 0 ..< image.height: + for x in 0 ..< image.width: + let pixel = image.data[y * image.width + x] + discard putPixel(native, cint(x), cint(y), channel(pixel.r, native.redMask) or + channel(pixel.g, native.greenMask) or channel(pixel.b, native.blueMask)) + discard putImage(display, window.handle, window.gc, native, 0, 0, 0, 0, + cuint(image.width), cuint(image.height)) + display.XFlush() + +proc hasWindows*(): bool = + for window in windows: + if not window.closed: return true diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index 2e09ee29..8f9e858b 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -19,6 +19,7 @@ type Window* = ref object onCloseRequest*: Callback onFrame*: Callback + onTouch*: TouchCallback onMove*: Callback onResize*: Callback onFocusChange*: Callback @@ -1748,3 +1749,6 @@ proc saveFileDialog*( let url = panel.URL() if url.int != 0: result = $url.path() + +proc touches*(window: Window): seq[TouchContact] = + @[] diff --git a/src/windy/platforms/win32/platform.nim b/src/windy/platforms/win32/platform.nim index e790375b..0c60ff1d 100644 --- a/src/windy/platforms/win32/platform.nim +++ b/src/windy/platforms/win32/platform.nim @@ -65,6 +65,7 @@ type Window* = ref object onCloseRequest*: Callback onFrame*: Callback + onTouch*: TouchCallback onMove*: Callback onResize*: Callback onFocusChange*: Callback @@ -2835,3 +2836,6 @@ proc openTempTextFile*(title, text: string) = createDir("tmp") writeFile("tmp/" & title, text) discard execShellCmd("notepad tmp/" & title) + +proc touches*(window: Window): seq[TouchContact] = + @[] diff --git a/tests/bench_native_upload.nim b/tests/bench_native_upload.nim new file mode 100644 index 00000000..cfb68a19 --- /dev/null +++ b/tests/bench_native_upload.nim @@ -0,0 +1,18 @@ +import + std/[monotimes, os, times], + pixie, + windy/platforms/linux/native/drm + +proc main() = + doAssert paramCount() == 1, "Choose a DRM connector." + let display = openDrm("/dev/dri/card0") + defer: display.close() + display.openOutput(@[paramStr(1)], 0) + let image = newImage(display.screens[0].width, display.screens[0].height) + image.fill(color(0.2, 0.4, 0.8, 1)) + let started = getMonoTime() + for iteration in 0 ..< 20: + display.upload(0, image) + echo "Upload mean us: ", (getMonoTime() - started).inMicroseconds div 20 + +main() diff --git a/tests/config.nims b/tests/config.nims index 2105526e..e35ab193 100644 --- a/tests/config.nims +++ b/tests/config.nims @@ -2,3 +2,15 @@ when defined(emscripten): include "../examples/config.nims" + +import std/os +let testBackends = getEnv("WINDY_TEST_BACKENDS") +if testBackends.len > 0: + switch("define", "useCpu") + switch("define", "windyBackends=" & testBackends) + +if getEnv("WINDY_TEST_TARGET") == "arm64": + switch("cpu", "arm64") + switch("gcc.exe", getEnv("MONO_RETRO_CC", "aarch64-unknown-linux-gnu-gcc")) + switch("gcc.linkerexe", getEnv("MONO_RETRO_CC", "aarch64-unknown-linux-gnu-gcc")) + switch("passL", "-Wl,--dynamic-linker=/lib/ld-linux-aarch64.so.1") diff --git a/tests/manual_direct_displays.nim b/tests/manual_direct_displays.nim new file mode 100644 index 00000000..497b1213 --- /dev/null +++ b/tests/manual_direct_displays.nim @@ -0,0 +1,36 @@ +import + std/os, + pixie, windy + +proc main() = + let useDrm = paramStr(1) == "drm" + if useDrm: + doAssert paramCount() == 3, "Choose two DRM connectors." + setLinuxBackend(if useDrm: DrmBackend else: FbdevBackend) + for attempt in 0 .. 1: + let first = newWindow("First output", ivec2(0, 0), vsync = false, + linuxOptions = LinuxWindowOptions(output: paramStr(2))) + var image = newImage(first.framebufferSize.x, first.framebufferSize.y) + image.fill(color(1, 0, 0, 1)) + first.presentPixels(image) + if useDrm: + let second = newWindow("Second output", ivec2(0, 0), vsync = false, + linuxOptions = LinuxWindowOptions(output: paramStr(3))) + image = newImage(second.framebufferSize.x, second.framebufferSize.y) + image.fill(color(0, 1, 0, 1)) + second.presentPixels(image) + var rejected = false + try: + discard newWindow("Duplicate", ivec2(0, 0), vsync = false, + linuxOptions = LinuxWindowOptions(output: paramStr(3))) + except IOError: rejected = true + doAssert rejected + first.close() + image.fill(color(0, 0, 1, 1)) + second.presentPixels(image) + second.close() + else: + first.close() + doAssert first.closed + +main() diff --git a/tests/test_linux_display.nim b/tests/test_linux_display.nim new file mode 100644 index 00000000..2c0ecb79 --- /dev/null +++ b/tests/test_linux_display.nim @@ -0,0 +1,52 @@ +import + std/os, + pixie, windy + +when HasX11: + import windy/platforms/linux/x11/[xlib, x] + + proc getImage(d: xlib.Display, drawable: x.Drawable, x, y: cint, + width, height: cuint, planes: culong, format: cint): pointer + {.cdecl, importc: "XGetImage", dynlib: libX11.} + proc getPixel(image: pointer, x, y: cint): culong + {.cdecl, importc: "XGetPixel", dynlib: libX11.} + proc destroyImage(image: pointer): cint + {.cdecl, importc: "XDestroyImage", dynlib: libX11.} + proc closeDisplay(d: xlib.Display): cint + {.cdecl, importc: "XCloseDisplay", dynlib: libX11.} + + setLinuxBackend(X11Backend) + let window = newWindow("Windy CPU regression", ivec2(64, 48), vsync = false) + let image = newImage(64, 48) + image.fill(color(1, 0, 0, 1)) + window.presentPixels(image) + pollEvents() + doAssert window.framebufferSize == ivec2(64, 48) + doAssert window.touches.len == 0 + let d = XOpenDisplay(cstring(getEnv("DISPLAY"))) + doAssert d != nil + d.XSync(false) + let captured = getImage(d, d.defaultRootWindow, 0, 0, 1, 1, culong.high, 2) + doAssert captured != nil + doAssert getPixel(captured, 0, 0) == 0xff0000 + discard destroyImage(captured) + discard closeDisplay(d) + when HasDirect: + var rejected = false + try: setLinuxBackend(FbdevBackend) + except WindyError: rejected = true + doAssert rejected + window.close() + pollEvents() + doAssert window.closed + +when HasFbdev: + setLinuxBackend(FbdevBackend) + var rejectedFramebuffer = false + try: + discard newWindow("Invalid framebuffer", ivec2(0, 0), vsync = false, + linuxOptions = LinuxWindowOptions(device: "/dev/null")) + except OSError: + rejectedFramebuffer = true + doAssert rejectedFramebuffer + setLinuxBackend(FbdevBackend) diff --git a/tests/test_linux_glx.nim b/tests/test_linux_glx.nim new file mode 100644 index 00000000..80777c0d --- /dev/null +++ b/tests/test_linux_glx.nim @@ -0,0 +1,13 @@ +import opengl, windy + +let window = newWindow("Windy GLX regression", ivec2(64, 48), vsync = false) +window.makeContextCurrent() +loadExtensions() +doAssert glGetString(GL_VERSION) != nil +for frame in 0 ..< 3: + glClearColor(0.2, 0.4, 0.8, 1) + glClear(GL_COLOR_BUFFER_BIT) + window.swapBuffers() + pollEvents() +window.close() +doAssert window.closed diff --git a/tests/test_native_input.nim b/tests/test_native_input.nim new file mode 100644 index 00000000..cb3c67f6 --- /dev/null +++ b/tests/test_native_input.nim @@ -0,0 +1,59 @@ +import + pixie, + windy/common, + windy/platforms/linux/native/[touch, pixels, framebuffer, drm] + +block: + let reader = TouchReader(width: 1024, height: 768, + minimumX: 100, maximumX: 1100, minimumY: 50, maximumY: 1050, + pending: @[Slot(id: -1), Slot(id: -1)], previous: @[Slot(id: -1), Slot(id: -1)]) + reader.pending[0] = Slot(id: 7, x: 100, y: 50) + reader.pending[1] = Slot(id: 9, x: 1100, y: 1050) + let began = reader.report() + doAssert began.len == 2 + doAssert began[0].phase == TouchBegin and began[0].contact.id == 7 + doAssert began[0].contact.position == vec2(0, 0) + doAssert began[1].contact.position == vec2(1023, 767) + doAssert reader.report().len == 0 + reader.pending[0].x = 600 + let moved = reader.report() + doAssert moved.len == 1 and moved[0].phase == TouchMove + doAssert moved[0].contact.id == 7 and moved[0].contact.position.x == 511 + reader.pending[0].id = 11 + let replaced = reader.report() + doAssert replaced.len == 2 + doAssert replaced[0].phase == TouchEnd and replaced[0].contact.id == 7 + doAssert replaced[1].phase == TouchBegin and replaced[1].contact.id == 11 + let cancelled = reader.cancel() + doAssert cancelled.len == 2 + for event in cancelled: doAssert event.phase == TouchCancel + doAssert reader.cancel().len == 0 + doAssert reader.report().len == 0 + +block: + for rotation in [0, 90, 180, 270]: + let reader = TouchReader(width: 4, height: 3, maximumX: 100, maximumY: 100, + rotation: rotation, pending: @[Slot(id: 1)], previous: @[Slot(id: -1)]) + let expected = case rotation + of 0: vec2(0, 0) + of 90: vec2(3, 0) + of 180: vec2(3, 2) + else: vec2(0, 2) + doAssert reader.report()[0].contact.position == expected + +block: + doAssert rgb(rgbx(255, 0, 0, 255)) == 0xff0000'u32 + doAssert rgb(rgbx(0, 255, 0, 255)) == 0x00ff00'u32 + doAssert rgb(rgbx(0, 0, 255, 255)) == 0x0000ff'u32 + doAssert packPixel(0xffffff, Bitfield(offset: 11, length: 5), + Bitfield(offset: 5, length: 6), Bitfield(length: 5), Bitfield()) == 0xffff + doAssert chooseCrtc([90'u32, 110, 130], 130, 5, []) == 130 + doAssert chooseCrtc([90'u32, 110, 130], 0, 5, [90'u32]) == 130 + +block: + var rejected = false + try: + discard openTouch("/dev/null", 20, 20, 0) + except OSError: + rejected = true + doAssert rejected From e9ed10a56f0e0da57ed6d1e68be947d1ad8a03ae Mon Sep 17 00:00:00 2001 From: monofuel Date: Tue, 27 Oct 2026 20:05:55 -0400 Subject: [PATCH 02/21] Speed up X11 CPU pixel uploads with packed row writes --- src/windy/platforms/linux/x11.nim | 20 +++++++++++++++++--- tests/test_linux_display.nim | 15 +++++++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/windy/platforms/linux/x11.nim b/src/windy/platforms/linux/x11.nim index add90913..2e3cb3d6 100644 --- a/src/windy/platforms/linux/x11.nim +++ b/src/windy/platforms/linux/x11.nim @@ -1466,6 +1466,8 @@ proc saveFileDialog*( proc touches*(window: Window): seq[TouchContact] = @[] when defined(useCpu): + const NativeByteOrder = when cpuEndian == littleEndian: 0 else: 1 + type CpuImage = object width, height, xoffset, format: cint data: cstring @@ -1509,11 +1511,23 @@ when defined(useCpu): native.data = cast[cstring](malloc(csize_t(native.bytesPerLine) * csize_t(image.height))) if native.data == nil: raise newException(WindyError, "Cannot allocate X11 pixel image") + let direct = native.bitsPerPixel == 32 and native.byteOrder == NativeByteOrder + var channels: array[3, array[256, culong]] + for value in 0 .. 255: + channels[0][value] = channel(uint8(value), native.redMask) + channels[1][value] = channel(uint8(value), native.greenMask) + channels[2][value] = channel(uint8(value), native.blueMask) + let destination = cast[ptr UncheckedArray[byte]](native.data) for y in 0 ..< image.height: + let row = cast[ptr UncheckedArray[uint32]](addr destination[y * int(native.bytesPerLine)]) for x in 0 ..< image.width: - let pixel = image.data[y * image.width + x] - discard putPixel(native, cint(x), cint(y), channel(pixel.r, native.redMask) or - channel(pixel.g, native.greenMask) or channel(pixel.b, native.blueMask)) + let + pixel = image.data[y * image.width + x] + packed = channels[0][pixel.r] or channels[1][pixel.g] or channels[2][pixel.b] + if direct: + row[x] = uint32(packed) + else: + discard putPixel(native, cint(x), cint(y), packed) discard putImage(display, window.handle, window.gc, native, 0, 0, 0, 0, cuint(image.width), cuint(image.height)) display.XFlush() diff --git a/tests/test_linux_display.nim b/tests/test_linux_display.nim index 2c0ecb79..bb32352b 100644 --- a/tests/test_linux_display.nim +++ b/tests/test_linux_display.nim @@ -17,8 +17,10 @@ when HasX11: setLinuxBackend(X11Backend) let window = newWindow("Windy CPU regression", ivec2(64, 48), vsync = false) - let image = newImage(64, 48) - image.fill(color(1, 0, 0, 1)) + let image = newImage(63, 47) + for index in 0 ..< image.data.len: + image.data[index] = rgbx(uint8(index mod 256), uint8((index div 63) * 5), + uint8((index mod 8) * 31), 255) window.presentPixels(image) pollEvents() doAssert window.framebufferSize == ivec2(64, 48) @@ -26,9 +28,14 @@ when HasX11: let d = XOpenDisplay(cstring(getEnv("DISPLAY"))) doAssert d != nil d.XSync(false) - let captured = getImage(d, d.defaultRootWindow, 0, 0, 1, 1, culong.high, 2) + let captured = getImage(d, d.defaultRootWindow, 0, 0, 63, 47, culong.high, 2) doAssert captured != nil - doAssert getPixel(captured, 0, 0) == 0xff0000 + for y in 0 ..< image.height: + for x in 0 ..< image.width: + let + pixel = image.data[y * image.width + x] + expected = culong(pixel.r) shl 16 or culong(pixel.g) shl 8 or culong(pixel.b) + doAssert getPixel(captured, cint(x), cint(y)) == expected discard destroyImage(captured) discard closeDisplay(d) when HasDirect: From 83699dd298ca92043255177af6abc1ff089fa6a0 Mon Sep 17 00:00:00 2001 From: monofuel Date: Tue, 27 Oct 2026 20:53:44 -0400 Subject: [PATCH 03/21] Speed up standard 32-bit framebuffer uploads --- Makefile | 1 + .../platforms/linux/native/framebuffer.nim | 31 +++++++++---- tests/test_linux_framebuffer.nim | 44 +++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 tests/test_linux_framebuffer.nim diff --git a/Makefile b/Makefile index 39570ac5..8f3470e5 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: test-native test-linux-cpu test-linux-combined test-native: nim r tests/test_native_input.nim + nim r tests/test_linux_framebuffer.nim test-linux-cpu: WINDY_TEST_BACKENDS=x11 nim r tests/test_linux_display.nim test-linux-combined: diff --git a/src/windy/platforms/linux/native/framebuffer.nim b/src/windy/platforms/linux/native/framebuffer.nim index 56f8afb2..05512e10 100644 --- a/src/windy/platforms/linux/native/framebuffer.nim +++ b/src/windy/platforms/linux/native/framebuffer.nim @@ -113,15 +113,30 @@ proc present*(fb: Framebuffer, image: Image) = bytesPerPixel = int(fb.variable.bits_per_pixel div 8) stride = int(fb.fixed.line_length) fb.touched = true - for y in 0 ..< image.height: - for x in 0 ..< image.width: + if cpuEndian == littleEndian and bytesPerPixel == 4 and stride mod 4 == 0 and fb.rotation == 0 and + fb.variable.red.length == 8 and fb.variable.green.length == 8 and + fb.variable.blue.length == 8: + let alpha = packPixel(0, fb.variable.red, fb.variable.green, + fb.variable.blue, fb.variable.transp) + for y in 0 ..< image.height: let - point = rotatedPoint(x, y, image.width, image.height, fb.rotation) - offset = point.y * stride + point.x * bytesPerPixel - pixel = packPixel(rgb(image.data[y * image.width + x]), fb.variable.red, - fb.variable.green, fb.variable.blue, fb.variable.transp) - for index in 0 ..< bytesPerPixel: - destination[offset + index] = byte((pixel shr (index * 8)) and 255) + source = cast[ptr UncheckedArray[ColorRGBX]](unsafeAddr image.data[y * image.width]) + row = cast[ptr UncheckedArray[uint32]](addr destination[y * stride]) + for x in 0 ..< image.width: + let pixel = source[x] + row[x] = uint32(pixel.r) shl fb.variable.red.offset or + uint32(pixel.g) shl fb.variable.green.offset or + uint32(pixel.b) shl fb.variable.blue.offset or alpha + else: + for y in 0 ..< image.height: + for x in 0 ..< image.width: + let + point = rotatedPoint(x, y, image.width, image.height, fb.rotation) + offset = point.y * stride + point.x * bytesPerPixel + pixel = packPixel(rgb(image.data[y * image.width + x]), fb.variable.red, + fb.variable.green, fb.variable.blue, fb.variable.transp) + for index in 0 ..< bytesPerPixel: + destination[offset + index] = byte((pixel shr (index * 8)) and 255) if not fb.activated: fb.variable.xoffset = 0 fb.variable.yoffset = 0 diff --git a/tests/test_linux_framebuffer.nim b/tests/test_linux_framebuffer.nim new file mode 100644 index 00000000..bfab1262 --- /dev/null +++ b/tests/test_linux_framebuffer.nim @@ -0,0 +1,44 @@ +include windy/platforms/linux/native/framebuffer + +proc checkPixels(bits: int, red, green, blue, alpha: Bitfield, rotation: int) = + ## Compare packed scanout bytes with the generic converter, including padding. + let + image = newImage(31, 17) + physicalWidth = if rotation in [90, 270]: image.height else: image.width + physicalHeight = if rotation in [90, 270]: image.width else: image.height + bytes = bits div 8 + stride = ((physicalWidth * bytes + 3) div 4 + 1) * 4 + var + memory = newSeq[byte](stride * physicalHeight + 8) + expected = newSeq[byte](memory.len) + for index in 0 ..< memory.len: + memory[index] = 0xa5 + expected[index] = 0xa5 + for y in 0 ..< image.height: + for x in 0 ..< image.width: + let + pixel = rgbx(uint8(x * 8), uint8(y * 15), uint8((x + y) * 5), 127) + packed = packPixel(rgb(pixel), red, green, blue, alpha) + point = rotatedPoint(x, y, image.width, image.height, rotation) + offset = 4 + point.y * stride + point.x * bytes + image.data[y * image.width + x] = pixel + for index in 0 ..< bytes: + expected[offset + index] = byte((packed shr (index * 8)) and 255) + let fb = Framebuffer(memory: addr memory[4], width: image.width, + height: image.height, rotation: rotation, activated: true, + variable: VariableInfo(bits_per_pixel: uint32(bits), red: red, + green: green, blue: blue, transp: alpha), + fixed: FixedInfo(line_length: uint32(stride))) + fb.present(image) + doAssert memory == expected + +for rotation in [0, 90, 180, 270]: + for redOffset in [0'u32, 16]: + for alphaBits in [0'u32, 8]: + checkPixels(32, Bitfield(offset: redOffset, length: 8), + Bitfield(offset: 8, length: 8), Bitfield(offset: 16 - redOffset, length: 8), + Bitfield(offset: 24, length: alphaBits), rotation) + checkPixels(24, Bitfield(offset: 16, length: 8), Bitfield(offset: 8, length: 8), + Bitfield(length: 8), Bitfield(), rotation) + checkPixels(16, Bitfield(offset: 11, length: 5), Bitfield(offset: 5, length: 6), + Bitfield(length: 5), Bitfield(), rotation) From 0c34bd7379f44d5b005786147013b2a036258bce Mon Sep 17 00:00:00 2001 From: monofuel Date: Tue, 27 Oct 2026 22:02:21 -0400 Subject: [PATCH 04/21] Use Xlib int return types for event predicates --- src/windy/platforms/linux/x11.nim | 12 ++++++------ src/windy/platforms/linux/x11/xevent.nim | 4 ++-- tests/test_linux_display.nim | 12 ++++++++++++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/windy/platforms/linux/x11.nim b/src/windy/platforms/linux/x11.nim index 2e3cb3d6..46c09ab0 100644 --- a/src/windy/platforms/linux/x11.nim +++ b/src/windy/platforms/linux/x11.nim @@ -872,8 +872,8 @@ proc pollEvents(window: Window) = d: Display, event: ptr XEvent, userData: pointer - ): bool {.cdecl.} = - event.any.window == cast[Window](userData).handle + ): cint {.cdecl.} = + cint(event.any.window == cast[Window](userData).handle) template pushButtonEvent( button: Button, @@ -895,7 +895,7 @@ proc pollEvents(window: Window) = proc handleRune(window: Window, rune: Rune) = handleRuneTemplate() - while display.XCheckIfEvent(ev.addr, checkEvent, cast[pointer](window)): + while display.XCheckIfEvent(ev.addr, checkEvent, cast[pointer](window)) != 0: case ev.kind of xeClientMessage: @@ -1231,12 +1231,12 @@ proc processClipboardEvents: bool = d: Display, event: ptr XEvent, userData: pointer - ): bool {.cdecl.} = - event.any.window == cast[XWindow](userData) + ): cint {.cdecl.} = + cint(event.any.window == cast[XWindow](userData)) while display.XCheckIfEvent( ev.addr, checkEvent, cast[pointer](clipboardWindow) - ): + ) != 0: case ev.kind of xeSelection: template e: untyped = ev.selection diff --git a/src/windy/platforms/linux/x11/xevent.nim b/src/windy/platforms/linux/x11/xevent.nim index 1147b566..ac0a20da 100644 --- a/src/windy/platforms/linux/x11/xevent.nim +++ b/src/windy/platforms/linux/x11/xevent.nim @@ -5,7 +5,7 @@ type d: Display, event: ptr XEvent, p: pointer - ): bool {.cdecl.} + ): cint {.cdecl.} ErrorHandleProc* = proc (d: Display, event: ptr XErrorEvent): bool {.cdecl.} XEventKind* {.size: cint.sizeof.} = enum @@ -432,7 +432,7 @@ type {.push, cdecl, dynlib: libX11, importc.} proc XCheckIfEvent*(d: Display; e: ptr XEvent, cb: IfEventProc, - userData: pointer): bool + userData: pointer): cint proc XSendEvent*(d: Display; window: Window, propogate: bool, mask: clong, e: ptr XEvent) proc Xutf8LookupString*(ic: XIC, e: ptr XKeyEvent, buffer: cstring, len: cint, diff --git a/tests/test_linux_display.nim b/tests/test_linux_display.nim index bb32352b..7aa2c00c 100644 --- a/tests/test_linux_display.nim +++ b/tests/test_linux_display.nim @@ -38,6 +38,18 @@ when HasX11: doAssert getPixel(captured, cint(x), cint(y)) == expected discard destroyImage(captured) discard closeDisplay(d) + let other = newWindow("Windy second CPU window", ivec2(64, 48), vsync = false) + other.focus() + for attempt in 0 ..< 20: + pollEvents() + sleep(1) + doAssert other.focused and not window.focused + window.focus() + for attempt in 0 ..< 20: + pollEvents() + sleep(1) + doAssert window.focused and not other.focused + other.close() when HasDirect: var rejected = false try: setLinuxBackend(FbdevBackend) From 799e062f90743a0931aae74217c19875a69be8ef Mon Sep 17 00:00:00 2001 From: monofuel Date: Thu, 29 Oct 2026 19:24:00 -0400 Subject: [PATCH 05/21] Support vblank page flips for DRM pixel windows --- Makefile | 1 + docs/linux-direct.md | 15 +- src/windy/platforms/linux/direct.nim | 4 +- src/windy/platforms/linux/native/drm.nim | 173 ++++++++++++++++------- src/windy/platforms/linux/platform.nim | 6 +- tests/manual_direct_displays.nim | 7 +- tests/test_linux_drm.nim | 135 ++++++++++++++++++ 7 files changed, 278 insertions(+), 63 deletions(-) create mode 100644 tests/test_linux_drm.nim diff --git a/Makefile b/Makefile index 8f3470e5..643eddbe 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ test-native: nim r tests/test_native_input.nim nim r tests/test_linux_framebuffer.nim + nim r tests/test_linux_drm.nim test-linux-cpu: WINDY_TEST_BACKENDS=x11 nim r tests/test_linux_display.nim test-linux-combined: diff --git a/docs/linux-direct.md b/docs/linux-direct.md index 779dd1f0..8d0bae26 100644 --- a/docs/linux-direct.md +++ b/docs/linux-direct.md @@ -20,7 +20,7 @@ Select the backend before creating windows: import pixie, windy setLinuxBackend(DrmBackend) -let window = newWindow("Panel", ivec2(0, 0), vsync = false, +let window = newWindow("Panel", ivec2(0, 0), vsync = true, linuxOptions = LinuxWindowOptions( device: "/dev/dri/card0", output: "DSI-1", @@ -52,8 +52,17 @@ desktop operations such as resizing, clipboard access or OpenGL contexts raise `WindyError`. Keyboard, mouse and controller input are not supplied by these direct backends; use the application's input library for controllers. -Presentation copies into scanout memory. There is no page-flip queue or vblank -pacing, so `vsync` must be false and tearing is possible. Normal close restores +DRM windows with `vsync=true` use two buffers. Presentation uploads into the +non-visible buffer and queues a switch at vblank. A subsequent upload waits for +the previous switch before reusing storage, with at most one pending flip per +screen. Outputs remain independent; the backend dispatches completion events for +all windows sharing the DRM device. Applications still control their frame rate. +Page-flip errors and a one-second completion timeout propagate to the caller. +Each additional buffer uses the panel's native width, pitch and height (about +3 MiB for a 1024×768 32-bit panel). + +DRM with `vsync=false` and fbdev use direct scanout writes, which can tear. +fbdev requires `vsync=false`. Normal close waits for pending DRM flips and restores the saved display state when it is still available. If the kernel retired the previous DRM framebuffer, the output is disabled instead. diff --git a/src/windy/platforms/linux/direct.nim b/src/windy/platforms/linux/direct.nim index 24eef9bd..4b099475 100644 --- a/src/windy/platforms/linux/direct.nim +++ b/src/windy/platforms/linux/direct.nim @@ -51,7 +51,7 @@ proc close*(window: Window) = break proc newDirectWindow*(title: string, backend: LinuxBackend, - options: LinuxWindowOptions): Window = + options: LinuxWindowOptions, vsync = false): Window = let path = if options.device.len > 0: options.device elif backend == FbdevBackend: "/dev/fb0" else: "/dev/dri/card0" result = Window(direct: DirectWindowState(title: title, device: path, backend: backend)) @@ -71,7 +71,7 @@ proc newDirectWindow*(title: string, backend: LinuxBackend, break if data.drm == nil: data.drm = openDrm(path) - data.drm.openOutput(@[options.output], options.rotation) + data.drm.openOutput(@[options.output], options.rotation, vsync) data.screen = data.drm.screens[^1] if options.touchDevice.len > 0: for window in windows: diff --git a/src/windy/platforms/linux/native/drm.nim b/src/windy/platforms/linux/native/drm.nim index 15fe4257..9a0e13a8 100644 --- a/src/windy/platforms/linux/native/drm.nim +++ b/src/windy/platforms/linux/native/drm.nim @@ -1,5 +1,5 @@ import - std/[dynlib, os, posix, strformat, strutils], + std/[dynlib, monotimes, os, posix, strformat, strutils, times], pixie, ./[framebuffer, pixels] @@ -68,6 +68,16 @@ type enums: pointer countBlobs: cint blobs: pointer + FlipHandler = proc(fd: cint, sequence, seconds, microseconds: cuint, + data: pointer) {.cdecl.} + EventContext = object + # Version 2 uses only this prefix of libdrm's extensible event context. + version: cint + vblankHandler, pageFlipHandler: FlipHandler + Scanout = object + buffer: DumbBuffer + memory: pointer + framebuffer: uint32 DrmApi = object library: LibHandle getResources: proc(fd: cint): ptr Resources {.cdecl.} @@ -80,6 +90,9 @@ type freeCrtc: proc(value: ptr Crtc) {.cdecl.} setCrtc: proc(fd: cint, id, buffer, x, y: uint32, connectors: ptr uint32, count: cint, mode: ptr ModeInfo): cint {.cdecl.} + pageFlip: proc(fd: cint, crtc, framebuffer, flags: uint32, + data: pointer): cint {.cdecl.} + handleEvent: proc(fd: cint, context: ptr EventContext): cint {.cdecl.} addFb: proc(fd: cint, width, height: uint32, depth, bpp: uint8, pitch, handle: uint32, id: ptr uint32): cint {.cdecl.} removeFb: proc(fd: cint, id: uint32): cint {.cdecl.} @@ -101,12 +114,13 @@ type name*: string width*, height*: int rotation: int - connector, crtc, framebuffer: uint32 + connector, crtc: uint32 mode: ModeInfo original: Crtc originalConnectors: seq[uint32] - buffer: DumbBuffer - memory: pointer + buffers: seq[Scanout] + front, draw: int + vsync, pending: bool pixels: seq[uint32] rgba: bool activated: bool @@ -115,8 +129,11 @@ type fd: cint master: bool screens*: seq[DrmScreen] + retiredScreens: seq[DrmScreen] const + FlipEvent = 1'u32 + FlipTimeoutMs = 1000 CreateDumb = 0xc02064b2.culong MapDumb = 0xc01064b3.culong DestroyDumb = 0xc00464b4.culong @@ -141,6 +158,7 @@ static: doAssert sizeof(MapBuffer) == 16 doAssert sizeof(FramebufferInfo) == 28 doAssert sizeof(GemClose) == 8 + doAssert sizeof(EventContext) == 24 proc loadDrm(): DrmApi = ## Load DRM only when a DRM display is requested. @@ -161,6 +179,8 @@ proc loadDrm(): DrmApi = bindSymbol(getCrtc, "drmModeGetCrtc") bindSymbol(freeCrtc, "drmModeFreeCrtc") bindSymbol(setCrtc, "drmModeSetCrtc") + bindSymbol(pageFlip, "drmModePageFlip") + bindSymbol(handleEvent, "drmHandleEvent") bindSymbol(addFb, "drmModeAddFB") bindSymbol(removeFb, "drmModeRmFB") bindSymbol(getFb, "drmModeGetFB") @@ -207,8 +227,42 @@ proc rgbaFormat(display: DrmDisplay, crtcIndex: int): uint32 = for formatIndex in 0 ..< int(plane.countFormats): if plane.formats[formatIndex] == format: return format +proc flipComplete(fd: cint, sequence, seconds, microseconds: cuint, + data: pointer) {.cdecl.} = + ## Mark a screen's queued buffer as scanned out after its vblank event. + let screen = cast[DrmScreen](data) + screen.front = screen.draw + screen.pending = false + +proc waitForFlip(display: DrmDisplay, screen: DrmScreen) = + ## Wait before reusing scanout storage, dispatching events for every output. + if not screen.pending: return + let deadline = getMonoTime() + initDuration(milliseconds = FlipTimeoutMs) + var context = EventContext(version: 2, pageFlipHandler: flipComplete) + while screen.pending: + let remaining = (deadline - getMonoTime()).inMilliseconds + if remaining <= 0: + raise newException(IOError, "Timed out waiting for DRM page flip on " & screen.name) + var descriptor = TPollfd(fd: display.fd, events: POLLIN) + let count = poll(addr descriptor, 1, cint(remaining)) + if count < 0: + if osLastError() == OSErrorCode(EINTR): continue + raiseOSError(osLastError(), "Waiting for DRM page flip") + if count == 0: continue + if (descriptor.revents and (POLLERR or POLLHUP or POLLNVAL)) != 0: + raise newException(IOError, "DRM event descriptor became unavailable") + if display.api.handleEvent(display.fd, addr context) < 0: + if osLastError() == OSErrorCode(EINTR): continue + raiseOSError(osLastError(), "Reading DRM page flip events") + proc closeScreen*(display: DrmDisplay, screen: DrmScreen) = var errors: seq[string] + try: + display.waitForFlip(screen) + except CatchableError as error: + errors.add error.msg + # A late event still contains this screen's address until the DRM fd closes. + display.retiredScreens.add screen if screen.activated: var original = screen.original @@ -238,18 +292,19 @@ proc closeScreen*(display: DrmDisplay, screen: DrmScreen) = original.y, connectorPointer, count, modePointer) < 0: errors.add "Restoring " & screen.name screen.activated = false - if screen.memory != nil: - if munmap(screen.memory, int(screen.buffer.size)) < 0: - errors.add "Unmapping " & screen.name - screen.memory = nil - if screen.framebuffer != 0: - if display.api.removeFb(display.fd, screen.framebuffer) < 0: - errors.add "Removing framebuffer for " & screen.name - screen.framebuffer = 0 - if screen.buffer.handle != 0: - if deviceIoctl(display.fd, DestroyDumb, addr screen.buffer.handle) < 0: - errors.add "Destroying buffer for " & screen.name - screen.buffer.handle = 0 + for scanout in screen.buffers.mitems: + if scanout.memory != nil: + if munmap(scanout.memory, int(scanout.buffer.size)) < 0: + errors.add "Unmapping " & screen.name + scanout.memory = nil + if scanout.framebuffer != 0: + if display.api.removeFb(display.fd, scanout.framebuffer) < 0: + errors.add "Removing framebuffer for " & screen.name + scanout.framebuffer = 0 + if scanout.buffer.handle != 0: + if deviceIoctl(display.fd, DestroyDumb, addr scanout.buffer.handle) < 0: + errors.add "Destroying buffer for " & screen.name + scanout.buffer.handle = 0 if errors.len > 0: raise newException(IOError, errors.join("; ")) @@ -267,6 +322,7 @@ proc close*(display: DrmDisplay) = if posix.close(display.fd) < 0: errors.add "Closing DRM device" display.fd = -1 + display.retiredScreens.setLen(0) if display.api.library != nil: unloadLib(display.api.library) display.api.library = nil @@ -291,7 +347,7 @@ proc chooseCrtc*(ids: openArray[uint32], current, possible: uint32, return id raise newException(IOError, "No independent display controller is available") -proc openOutput*(result: DrmDisplay, outputs: seq[string], rotation: int) = +proc openOutput*(result: DrmDisplay, outputs: seq[string], rotation: int, vsync = false) = ## Prepare independent CPU scanout buffers without changing the active display. if rotation notin [0, 90, 180, 270]: raise newException(ValueError, "Rotation must be 0, 90, 180 or 270") @@ -362,7 +418,7 @@ proc openOutput*(result: DrmDisplay, outputs: seq[string], rotation: int) = if original == nil: raiseOSError(osLastError(), "Saving DRM scanout") let screen = DrmScreen(name: name, connector: connector.id, crtc: id, - original: original[], mode: connector.modes[0], rotation: rotation) + original: original[], mode: connector.modes[0], rotation: rotation, vsync: vsync) api.freeCrtc(original) result.screens.add screen used.add id @@ -382,35 +438,37 @@ proc openOutput*(result: DrmDisplay, outputs: seq[string], rotation: int) = api.freeEncoder(encoder) screen.width = int(if rotation in [90, 270]: screen.mode.vdisplay else: screen.mode.hdisplay) screen.height = int(if rotation in [90, 270]: screen.mode.hdisplay else: screen.mode.vdisplay) - screen.buffer = DumbBuffer(width: uint32(screen.mode.hdisplay), - height: uint32(screen.mode.vdisplay), bpp: 32) - if deviceIoctl(result.fd, CreateDumb, addr screen.buffer) < 0: - raiseOSError(osLastError(), "Allocating DRM scanout buffer") - if screen.buffer.pitch < screen.buffer.width * 4 or - screen.buffer.size < uint64(screen.buffer.pitch) * screen.buffer.height or - screen.buffer.size > MaximumBufferBytes: - raise newException(IOError, "Unsupported DRM buffer geometry") let format = result.rgbaFormat(crtcs.find(id)) screen.rgba = format != 0 - if screen.rgba: - var - handles = [screen.buffer.handle, 0'u32, 0, 0] - pitches = [screen.buffer.pitch, 0'u32, 0, 0] - offsets: array[4, uint32] - if api.addFb2(result.fd, screen.buffer.width, screen.buffer.height, format, - addr handles[0], addr pitches[0], addr offsets[0], addr screen.framebuffer, 0) < 0: - raiseOSError(osLastError(), "Registering RGBA DRM framebuffer") - elif api.addFb(result.fd, screen.buffer.width, screen.buffer.height, 24, 32, - screen.buffer.pitch, screen.buffer.handle, addr screen.framebuffer) < 0: - raiseOSError(osLastError(), "Registering DRM framebuffer") - var mapping = MapBuffer(handle: screen.buffer.handle) - if deviceIoctl(result.fd, MapDumb, addr mapping) < 0: - raiseOSError(osLastError(), "Mapping DRM buffer") - screen.memory = mmap(nil, int(screen.buffer.size), PROT_READ or PROT_WRITE, - MAP_SHARED, result.fd, Off(mapping.offset)) - if screen.memory == MAP_FAILED: - screen.memory = nil - raiseOSError(osLastError(), "Mapping DRM scanout memory") + screen.buffers.setLen(if vsync: 2 else: 1) + for scanout in screen.buffers.mitems: + scanout.buffer = DumbBuffer(width: uint32(screen.mode.hdisplay), + height: uint32(screen.mode.vdisplay), bpp: 32) + if deviceIoctl(result.fd, CreateDumb, addr scanout.buffer) < 0: + raiseOSError(osLastError(), "Allocating DRM scanout buffer") + if scanout.buffer.pitch < scanout.buffer.width * 4 or + scanout.buffer.size < uint64(scanout.buffer.pitch) * scanout.buffer.height or + scanout.buffer.size > MaximumBufferBytes: + raise newException(IOError, "Unsupported DRM buffer geometry") + if screen.rgba: + var + handles = [scanout.buffer.handle, 0'u32, 0, 0] + pitches = [scanout.buffer.pitch, 0'u32, 0, 0] + offsets: array[4, uint32] + if api.addFb2(result.fd, scanout.buffer.width, scanout.buffer.height, format, + addr handles[0], addr pitches[0], addr offsets[0], addr scanout.framebuffer, 0) < 0: + raiseOSError(osLastError(), "Registering RGBA DRM framebuffer") + elif api.addFb(result.fd, scanout.buffer.width, scanout.buffer.height, 24, 32, + scanout.buffer.pitch, scanout.buffer.handle, addr scanout.framebuffer) < 0: + raiseOSError(osLastError(), "Registering DRM framebuffer") + var mapping = MapBuffer(handle: scanout.buffer.handle) + if deviceIoctl(result.fd, MapDumb, addr mapping) < 0: + raiseOSError(osLastError(), "Mapping DRM buffer") + scanout.memory = mmap(nil, int(scanout.buffer.size), PROT_READ or PROT_WRITE, + MAP_SHARED, result.fd, Off(mapping.offset)) + if scanout.memory == MAP_FAILED: + scanout.memory = nil + raiseOSError(osLastError(), "Mapping DRM scanout memory") except: while result.screens.len > originalCount: let screen = result.screens.pop() @@ -418,14 +476,17 @@ proc openOutput*(result: DrmDisplay, outputs: seq[string], rotation: int) = raise proc upload*(display: DrmDisplay, index: int, image: Image) = - ## Copy pixels into scanout memory without changing the active controller. + ## Upload a frame, waiting before reusing storage still owned by scanout. let screen = display.screens[index] if image.width != screen.width or image.height != screen.height: raise newException(ValueError, "Canvas does not match DRM screen") - let destination = cast[ptr UncheckedArray[uint32]](screen.memory) + display.waitForFlip(screen) + screen.draw = if screen.vsync and screen.activated: 1 - screen.front else: 0 + let scanout = screen.buffers[screen.draw] + let destination = cast[ptr UncheckedArray[uint32]](scanout.memory) if screen.rgba and screen.rotation == 0: for row in 0 ..< image.height: - copyMem(addr destination[row * int(screen.buffer.pitch) div 4], + copyMem(addr destination[row * int(scanout.buffer.pitch) div 4], unsafeAddr image.data[row * image.width], image.width * 4) return screen.pixels.setLen(image.data.len) @@ -436,12 +497,12 @@ proc upload*(display: DrmDisplay, index: int, image: Image) = for y in 0 ..< image.height: for x in 0 ..< image.width: let point = rotatedPoint(x, y, image.width, image.height, screen.rotation) - screen.pixels[point.y * int(screen.buffer.width) + point.x] = + screen.pixels[point.y * int(scanout.buffer.width) + point.x] = (if screen.rgba: cast[uint32](image.data[y * image.width + x]) else: rgb(image.data[y * image.width + x])) - for row in 0 ..< int(screen.buffer.height): - copyMem(addr destination[row * int(screen.buffer.pitch) div 4], - addr screen.pixels[row * int(screen.buffer.width)], int(screen.buffer.width) * 4) + for row in 0 ..< int(scanout.buffer.height): + copyMem(addr destination[row * int(scanout.buffer.pitch) div 4], + addr screen.pixels[row * int(scanout.buffer.width)], int(scanout.buffer.width) * 4) proc present*(display: DrmDisplay, index: int, image: Image) = display.upload(index, image) @@ -451,10 +512,16 @@ proc present*(display: DrmDisplay, index: int, image: Image) = if display.api.setMaster(display.fd) < 0: raiseOSError(osLastError(), "Acquiring DRM master; another display application may be running") display.master = true - if display.api.setCrtc(display.fd, screen.crtc, screen.framebuffer, 0, 0, + if display.api.setCrtc(display.fd, screen.crtc, screen.buffers[screen.draw].framebuffer, 0, 0, addr screen.connector, 1, addr screen.mode) < 0: raiseOSError(osLastError(), "Displaying " & screen.name) screen.activated = true + screen.front = screen.draw + elif screen.vsync: + if display.api.pageFlip(display.fd, screen.crtc, + screen.buffers[screen.draw].framebuffer, FlipEvent, cast[pointer](screen)) < 0: + raiseOSError(osLastError(), "Queuing DRM page flip on " & screen.name) + screen.pending = true proc describe*(screen: DrmScreen): string = ## Describe the selected connector, logical geometry and controller. diff --git a/src/windy/platforms/linux/platform.nim b/src/windy/platforms/linux/platform.nim index 7a2fbaa1..1a86728b 100644 --- a/src/windy/platforms/linux/platform.nim +++ b/src/windy/platforms/linux/platform.nim @@ -46,9 +46,9 @@ else: return x11.newWindow(title, size, style, visible, vsync, openglVersion, msaa, depthBits, stencilBits, linuxOptions) else: raise newException(WindyError, "X11 backend was not compiled") - if vsync: - raise newException(WindyError, "Direct pixel presentation requires vsync=false") - direct.newDirectWindow(title, selected, linuxOptions) + if vsync and selected == FbdevBackend: + raise newException(WindyError, "Framebuffer pixel presentation requires vsync=false") + direct.newDirectWindow(title, selected, linuxOptions, vsync) proc pollEvents*() = if selected == X11Backend: diff --git a/tests/manual_direct_displays.nim b/tests/manual_direct_displays.nim index 497b1213..33335f20 100644 --- a/tests/manual_direct_displays.nim +++ b/tests/manual_direct_displays.nim @@ -8,23 +8,26 @@ proc main() = doAssert paramCount() == 3, "Choose two DRM connectors." setLinuxBackend(if useDrm: DrmBackend else: FbdevBackend) for attempt in 0 .. 1: - let first = newWindow("First output", ivec2(0, 0), vsync = false, + let first = newWindow("First output", ivec2(0, 0), vsync = useDrm, linuxOptions = LinuxWindowOptions(output: paramStr(2))) var image = newImage(first.framebufferSize.x, first.framebufferSize.y) image.fill(color(1, 0, 0, 1)) + let firstImage = image first.presentPixels(image) if useDrm: - let second = newWindow("Second output", ivec2(0, 0), vsync = false, + let second = newWindow("Second output", ivec2(0, 0), vsync = true, linuxOptions = LinuxWindowOptions(output: paramStr(3))) image = newImage(second.framebufferSize.x, second.framebufferSize.y) image.fill(color(0, 1, 0, 1)) second.presentPixels(image) + second.presentPixels(image) var rejected = false try: discard newWindow("Duplicate", ivec2(0, 0), vsync = false, linuxOptions = LinuxWindowOptions(output: paramStr(3))) except IOError: rejected = true doAssert rejected + first.presentPixels(firstImage) first.close() image.fill(color(0, 0, 1, 1)) second.presentPixels(image) diff --git a/tests/test_linux_drm.nim b/tests/test_linux_drm.nim new file mode 100644 index 00000000..1adc0505 --- /dev/null +++ b/tests/test_linux_drm.nim @@ -0,0 +1,135 @@ +include windy/platforms/linux/native/drm + +var + signalFd: cint + queued: seq[pointer] + modesets, flips: int + failFlip, failEvents: bool + +proc setCrtc(fd: cint, id, buffer, x, y: uint32, connectors: ptr uint32, + count: cint, mode: ptr ModeInfo): cint {.cdecl.} = + ## Capture activation and restore calls without changing a physical display. + inc modesets + +proc pageFlip(fd: cint, crtc, framebuffer, flags: uint32, data: pointer): cint {.cdecl.} = + ## Queue completion notifications while preserving the active buffer. + doAssert flags == FlipEvent + if failFlip: return -1 + inc flips + queued.add data + var signal = 'F' + doAssert posix.write(signalFd, addr signal, 1) == 1 + +proc handleEvent(fd: cint, context: ptr EventContext): cint {.cdecl.} = + ## Deliver a completion through the real callback signature after polling a pipe. + if failEvents: return -1 + doAssert context.version == 2 + var signal: char + doAssert posix.read(fd, addr signal, 1) == 1 + let data = queued[0] + queued.delete(0) + context.pageFlipHandler(fd, 0, 0, 0, data) + +proc removeFb(fd: cint, id: uint32): cint {.cdecl.} = + ## Accept cleanup of a test-owned framebuffer registration. + discard + +proc newScreen(vsync: bool): DrmScreen = + ## Allocate padded anonymous buffers with the same lifetime as DRM mappings. + result = DrmScreen(name: "Test", width: 2, height: 2, rgba: true, vsync: vsync) + result.buffers.setLen(if vsync: 2 else: 1) + for index, scanout in result.buffers.mpairs: + scanout.buffer = DumbBuffer(width: 2, height: 2, pitch: 12, size: 24) + scanout.framebuffer = uint32(index + 1) + scanout.memory = mmap(nil, 24, PROT_READ or PROT_WRITE, MAP_PRIVATE or MAP_ANONYMOUS, -1, 0) + doAssert scanout.memory != MAP_FAILED + +proc firstPixel(screen: DrmScreen, index: int): ColorRGBX = + ## Observe a buffer without retaining a mutable alias. + cast[ptr ColorRGBX](screen.buffers[index].memory)[] + +proc main() = + ## Verify independent page flips, buffer ownership, failures and pending cleanup. + var pipeFds: array[2, cint] + doAssert pipe(pipeFds) == 0 + signalFd = pipeFds[1] + defer: + doAssert posix.close(pipeFds[0]) == 0 + doAssert posix.close(pipeFds[1]) == 0 + let + top = newScreen(true) + bottom = newScreen(true) + unsynced = newScreen(false) + display = DrmDisplay(fd: pipeFds[0], master: true, screens: @[top, bottom, unsynced], + api: DrmApi(setCrtc: setCrtc, pageFlip: pageFlip, handleEvent: handleEvent, removeFb: removeFb)) + image = newImage(2, 2) + image.fill(rgbx(255, 0, 0, 255)) + display.present(0, image) + display.present(1, image) + doAssert modesets == 2 and flips == 0 + image.fill(rgbx(0, 255, 0, 255)) + display.present(0, image) + display.present(1, image) + doAssert flips == 2 and top.pending and bottom.pending + doAssert top.firstPixel(0) == rgbx(255, 0, 0, 255) + doAssert top.firstPixel(1) == rgbx(0, 255, 0, 255) + doAssert top.front == 0 + image.fill(rgbx(0, 0, 255, 255)) + display.present(1, image) + doAssert not top.pending and top.front == 1 + doAssert bottom.pending and bottom.front == 1 + doAssert bottom.firstPixel(1) == rgbx(0, 255, 0, 255) + doAssert bottom.firstPixel(0) == rgbx(0, 0, 255, 255) + display.present(0, image) + doAssert top.firstPixel(1) == rgbx(0, 255, 0, 255) + doAssert top.firstPixel(0) == rgbx(0, 0, 255, 255) + display.closeScreen(bottom) + display.closeScreen(top) + doAssert not top.pending and not bottom.pending and queued.len == 0 + doAssert modesets == 4 + + display.present(2, image) + image.fill(rgbx(12, 34, 56, 255)) + display.present(2, image) + doAssert unsynced.firstPixel(0) == rgbx(12, 34, 56, 255) + doAssert not unsynced.pending and flips == 4 + display.closeScreen(unsynced) + + let failed = newScreen(true) + display.screens.add failed + display.present(3, image) + failFlip = true + var rejected = false + try: display.present(3, image) + except OSError: rejected = true + doAssert rejected and not failed.pending + display.closeScreen(failed) + + failFlip = false + let late = newScreen(true) + display.screens.add late + display.present(4, image) + display.present(4, image) + failEvents = true + rejected = false + try: display.closeScreen(late) + except IOError: rejected = true + doAssert rejected and display.retiredScreens == @[late] + display.screens.delete(4) + failEvents = false + let next = newScreen(true) + display.screens.add next + display.present(4, image) + display.present(4, image) + display.waitForFlip(next) + doAssert not late.pending and not next.pending and queued.len == 0 + next.pending = true + rejected = false + try: display.waitForFlip(next) + except IOError as error: + rejected = error.msg.startsWith("Timed out") + doAssert rejected + next.pending = false + display.closeScreen(next) + +main() From 1f1ca152fa3351df381c1a0a49785a0b4afa0465 Mon Sep 17 00:00:00 2001 From: monofuel Date: Thu, 29 Oct 2026 21:55:12 -0400 Subject: [PATCH 06/21] Add standalone GBM EGL paired DRM presentation probe --- docs/drm-gpu-proof.md | 77 ++++ tests/config.nims | 4 + tests/drm_gpu_patterns.nim | 87 +++++ tests/manual_drm_gpu.nim | 617 ++++++++++++++++++++++++++++++++ tests/test_drm_gpu_patterns.nim | 50 +++ 5 files changed, 835 insertions(+) create mode 100644 docs/drm-gpu-proof.md create mode 100644 tests/drm_gpu_patterns.nim create mode 100644 tests/manual_drm_gpu.nim create mode 100644 tests/test_drm_gpu_patterns.nim diff --git a/docs/drm-gpu-proof.md b/docs/drm-gpu-proof.md new file mode 100644 index 00000000..2686e8e3 --- /dev/null +++ b/docs/drm-gpu-proof.md @@ -0,0 +1,77 @@ +# Standalone DRM/GBM/EGL proof + +`tests/manual_drm_gpu.nim` is an experiment, not a new Windy rendering API. +The production X11, fbdev and CPU DRM backends are unchanged. Its private +bindings use the existing libdrm loader and dynamically load libgbm, libEGL and +libGLESv2. It requires a 64-bit little-endian Linux system with two equally sized +connected outputs, hardware Mesa rendering and exclusive display ownership. + +Compile with `nim c tests/manual_drm_gpu.nim`. Its project-specific test +configuration enables release/danger unless `debug` is defined. For ARM64 use +the existing `WINDY_TEST_TARGET=arm64` and `MONO_RETRO_CC` configuration. No new +Nim dependency is needed. + +``` +manual_drm_gpu CARD TOP_CONNECTOR BOTTOM_CONNECTOR ds|earthbound xbgr|xrgb gpu|cpu|inspect [seconds] +``` + +`inspect` initializes EGL, renders and checks pixels, but never acquires DRM +master or changes scanout. Other modes temporarily own both displays. Stop the +display service before running them; use an outer timeout and restore the service +on exit. Duration defaults to eight seconds and accepts only 1..8. Initialization +and cleanup are additional to the measured loop, so allow a small watchdog grace. +Do not run beside games, compiler jobs or other performance tests. + +## Rendering and paired presentation + +- One native-size combined texture contains both source panels. XBGR uploads + opaque R,G,B,255 bytes; XRGB uploads B,G,R,255. A shader handles the latter's + channel interpretation without changing the source buffer on the CPU. +- One linear XRGB GBM surface contains two full-resolution output regions. + Nearest-neighbour quads enlarge each source region by an integer and clear the + remaining border to black. The `ds` layout uses 256×192 per panel; `earthbound` + uses 256×224. Panel dimensions come from DRM, not board identifiers. +- `inspect` compares every rendered pixel against the integer-scaled pattern, + including both source regions and borders. This verifies GPU rendering; + physical screen order/orientation and scanout still need manual observation. +- Primary planes crop the two output regions at 1:1. Initial activation uses + one atomic TEST_ONLY followed by one blocking modeset. Unsupported shared + scanout is an error; there is no automatic second backend. +- Subsequent updates change both plane framebuffer IDs in one nonblocking, + vblank-synchronized atomic commit. Version-three DRM events identify each CRTC. + The previous GBM buffer is retained until both events arrive, in either order. + There is at most one pending paired commit. +- `glFinish` explicitly completes rendering before KMS sees the buffer. Its + cost is measured. Fence-based overlap is future work, not implied by this proof. +- Completion timestamps measure software-visible skew between outputs; a single + atomic transaction does not guarantee physically phase-locked panels. The + moving white markers on both panels represent the same synthetic frame. + +## Comparison and recovery + +The CPU mode uses the same source animation, row expansion/row replication and +border-only clears, then the existing full-resolution DRM upload/page-flip path. +It is a synthetic presentation reference, not an emulator benchmark. Pixel +conversion is scalar in this diagnostic; the player's XRGB converter uses SIMD. +Unchanged-panel caching is intentionally absent because the pattern moves. + +Reports include generation, upload, rendering/finish, submission and completion +waits, mean/p95/max, process CPU time per frame, frames over 20 ms and paired +completion skew. CPU submission includes full-panel uploads. GPU rendering +includes EGL swap/GBM acquisition. Never compare the two labels as isolated +equivalent operations; compare total process CPU and the complete pipeline. + +Normal exit restores the captured plane/connector/CRTC state before releasing +buffers. Cleanup errors are reported while remaining resources are still +released. If atomic restoration fails, outputs are disabled before freeing GPU +storage. An outer service-restoring wrapper is needed for fatal signals or a +watchdog kill; Nim defers cannot handle SIGKILL. + +Run `nim r tests/test_drm_gpu_patterns.nim` for colours, both layouts, CPU output, +both completion orders and rejected duplicate/unexpected events. Existing +`tests/test_linux_drm.nim` covers the unchanged CPU DRM lifecycle. + +## Device results + +Pending physical validation. A successful build or advertised DRM format does +not establish hardware rendering, paired scanout acceptance or a performance win. diff --git a/tests/config.nims b/tests/config.nims index e35ab193..fa17b588 100644 --- a/tests/config.nims +++ b/tests/config.nims @@ -4,6 +4,10 @@ when defined(emscripten): include "../examples/config.nims" import std/os +if projectName() == "manual_drm_gpu" and not defined(debug): + # Measure the same shipped settings as the graphical applications. + switch("define", "release") + switch("define", "danger") let testBackends = getEnv("WINDY_TEST_BACKENDS") if testBackends.len > 0: switch("define", "useCpu") diff --git a/tests/drm_gpu_patterns.nim b/tests/drm_gpu_patterns.nim new file mode 100644 index 00000000..aa857be4 --- /dev/null +++ b/tests/drm_gpu_patterns.nim @@ -0,0 +1,87 @@ +type + PixelLayout* = enum + xbgrPixels, xrgbPixels + Viewport* = object + x*, y*, width*, height*: int + PairedFlip* = object + crtcs*: array[2, uint32] + completed*: set[range[0..1]] + timestamps*: array[2, uint64] + +const + SourceWidth* = 256 + +proc fit*(width, height, panelWidth, panelHeight: int): Viewport = + ## Center a positive integer enlargement inside a panel. + if min(width, height) <= 0 or min(panelWidth, panelHeight) <= 0: + raise newException(ValueError, "Dimensions must be positive") + let scale = min(panelWidth div width, panelHeight div height) + if scale < 1: + raise newException(ValueError, "Source does not fit the panel") + result.width = width * scale + result.height = height * scale + result.x = (panelWidth - result.width) div 2 + result.y = (panelHeight - result.height) div 2 + +proc pixel*(x, y, frame, panelHeight: int): uint32 = + ## Generate opaque colour bars, single-pixel checks and a moving white seam. + let + panel = y div panelHeight + localY = y mod panelHeight + bars = [0xff0000ff'u32, 0xff00ff00'u32, 0xffff0000'u32, 0xffffffff'u32] + if x == frame mod SourceWidth or localY == frame mod panelHeight: + return 0xffffffff'u32 + if localY < panelHeight div 3: + return bars[(x div 64 + panel) mod bars.len] + if localY < panelHeight * 2 div 3: + return if (x + localY) mod 2 == 0: 0xffffffff'u32 else: 0xff000000'u32 + if panel == 0: 0xff00ffff'u32 else: 0xffffff00'u32 + +proc pack*(rgba: uint32, layout: PixelLayout): uint32 = + ## Preserve XBGR bytes or exchange red and blue for a standard XRGB source. + if layout == xbgrPixels: rgba + else: (rgba and 0xff00ff00'u32) or (rgba and 255) shl 16 or (rgba shr 16 and 255) + +proc generate*(data: var seq[uint32], panelHeight, frame: int, layout: PixelLayout) = + ## Fill one combined frame with an identical animation for both benchmarks. + data.setLen(SourceWidth * panelHeight * 2) + for y in 0 ..< panelHeight * 2: + for x in 0 ..< SourceWidth: + data[y * SourceWidth + x] = pack(pixel(x, y, frame, panelHeight), layout) + +proc complete*(pair: var PairedFlip, crtc: uint32, timestamp: uint64) = + ## Record exactly one completion per output before its buffer can be retired. + for index in 0..1: + if pair.crtcs[index] == crtc: + if index in pair.completed: + raise newException(ValueError, "Duplicate flip completion") + pair.completed.incl index + pair.timestamps[index] = timestamp + return + raise newException(ValueError, "Completion from an unexpected CRTC") + +proc ready*(pair: PairedFlip): bool = + ## Return whether both consumers have finished the paired update. + pair.completed == {0, 1} + +proc scalePanel*[T](source: seq[uint32], panel, sourceHeight: int, layout: PixelLayout, + output: var seq[T], width, height: int) = + ## Match the player's CPU row expansion, row copies and border-only clears. + let + view = fit(SourceWidth, sourceHeight, width, height) + scale = view.width div SourceWidth + static: doAssert sizeof(T) == sizeof(uint32) + output.setLen(width * height) + for y in 0 ..< height: + if y < view.y or y >= view.y + view.height: + for x in 0 ..< width: output[y * width + x] = cast[T](0xff000000'u32) + else: + for x in 0 ..< view.x: output[y * width + x] = cast[T](0xff000000'u32) + for x in view.x + view.width ..< width: output[y * width + x] = cast[T](0xff000000'u32) + for sourceY in 0 ..< sourceHeight: + let target = (view.y + sourceY * scale) * width + view.x + for sourceX in 0 ..< SourceWidth: + let colour = pack(source[(panel * sourceHeight + sourceY) * SourceWidth + sourceX], layout) + for repeat in 0 ..< scale: output[target + sourceX * scale + repeat] = cast[T](colour) + for repeat in 1 ..< scale: + copyMem(addr output[target + repeat * width], addr output[target], view.width * sizeof(uint32)) diff --git a/tests/manual_drm_gpu.nim b/tests/manual_drm_gpu.nim new file mode 100644 index 00000000..0bcd86c7 --- /dev/null +++ b/tests/manual_drm_gpu.nim @@ -0,0 +1,617 @@ +# This diagnostic deliberately keeps experimental bindings outside Windy's API. +import + std/[algorithm, parseutils], + ./drm_gpu_patterns + +include windy/platforms/linux/native/drm + +type + GbmHandle {.union.} = object + pointerValue: pointer + u32: uint32 + u64: uint64 + AtomicRequest = pointer + AtomicApi = object + alloc: proc(): AtomicRequest {.cdecl.} + free: proc(request: AtomicRequest) {.cdecl.} + add: proc(request: AtomicRequest, id, property: uint32, value: uint64): cint {.cdecl.} + commit: proc(fd: cint, request: AtomicRequest, flags: uint32, data: pointer): cint {.cdecl.} + createBlob: proc(fd: cint, data: pointer, size: csize_t, id: ptr uint32): cint {.cdecl.} + destroyBlob: proc(fd: cint, id: uint32): cint {.cdecl.} + AtomicValue = object + id, property: uint32 + value: uint64 + PairEvents = object + version: cint + vblank, flip: FlipHandler + flip2: proc(fd: cint, sequence, seconds, microseconds, crtc: cuint, + data: pointer) {.cdecl.} + Probe = ref object + display: DrmDisplay + atomic: AtomicApi + restore, properties: seq[AtomicValue] + blobs: seq[uint32] + planes: array[2, uint32] + fbProperties: array[2, uint32] + pair: PairedFlip + armed, pending: bool + callbackError: string + gbm, surface, egl, context, eglSurface: pointer + program, texture: uint32 + gbmFrames: seq[tuple[bo: pointer, fb: uint32]] + locked: seq[pointer] + front: pointer + pixels: seq[uint32] + panelWidth, panelHeight, sourceHeight: int + layout: PixelLayout + skew: seq[float64] + Samples = object + generate, upload, render, submit, wait: seq[float64] + +const + PlaneObject = 0xeeeeeeee'u32 + CrtcObject = 0xcccccccc'u32 + ConnectorObject = 0xc0c0c0c0'u32 + AtomicTest = 0x100'u32 + AtomicNonblock = 0x200'u32 + AllowModeset = 0x400'u32 + Xrgb8888 = 0x34325258'u32 + GlTexture = 0x0de1'u32 + GlRgba = 0x1908'u32 + GlByte = 0x1401'u32 + GlFloat = 0x1406'u32 + GlTriangles = 4'u32 + GlColourBuffer = 0x4000'u32 + GlRenderer = 0x1f01'u32 + GlVersion = 0x1f02'u32 + GlVertexShader = 0x8b31'u32 + GlFragmentShader = 0x8b30'u32 + GlCompileStatus = 0x8b81'u32 + GlLinkStatus = 0x8b82'u32 + GlMagFilter = 0x2800'u32 + GlMinFilter = 0x2801'u32 + GlWrapS = 0x2802'u32 + GlWrapT = 0x2803'u32 + GlNearest = 0x2600.cint + GlClampEdge = 0x812f.cint + EglGbm = 0x31d7'u32 + EglEsApi = 0x30a0'u32 + EglNativeVisual = 0x302e.cint + EglClientVersion = 0x3098.cint + AtomicCapability = 3'u64 + PrimaryPlane = 1'u64 + GbmScanoutRendering = 5'u32 + EglNone = 0x3038.cint + EglConfigAttributes = [0x3033.cint, 4, 0x3040, 4, 0x3024, 8, + 0x3023, 8, 0x3022, 8, 0x3025, 0, 0x3026, 0, EglNone] + VertexSource = "attribute vec2 position; attribute vec2 uv; varying vec2 tex; void main(){tex=uv;gl_Position=vec4(position,0.0,1.0);}" + FragmentSource = "precision highp float; varying vec2 tex; uniform sampler2D image; uniform float swapRB; void main(){vec4 c=texture2D(image,tex);gl_FragColor=vec4(mix(c.rgb,c.bgr,swapRB),1.0);}" + +static: + doAssert cpuEndian == littleEndian + doAssert sizeof(GbmHandle) == 8 + doAssert sizeof(PairEvents) == 32 + +{.push cdecl, importc, dynlib: "libgbm.so.1".} +proc gbm_create_device(fd: cint): pointer +proc gbm_device_destroy(device: pointer) +proc gbm_surface_create_with_modifiers2(device: pointer, width, height, format: uint32, + modifiers: ptr uint64, count, flags: uint32): pointer +proc gbm_surface_destroy(surface: pointer) +proc gbm_surface_lock_front_buffer(surface: pointer): pointer +proc gbm_surface_release_buffer(surface, bo: pointer) +proc gbm_bo_get_handle(bo: pointer): GbmHandle +proc gbm_bo_get_stride(bo: pointer): uint32 +proc gbm_bo_get_modifier(bo: pointer): uint64 +{.pop.} + +{.push cdecl, importc, dynlib: "libEGL.so.1".} +proc eglGetPlatformDisplay(platform: cuint, native: pointer, attributes: pointer): pointer +proc eglInitialize(display: pointer, major, minor: ptr cint): cuint +proc eglBindAPI(api: cuint): cuint +proc eglChooseConfig(display: pointer, attributes: ptr cint, configs: ptr pointer, + count: cint, found: ptr cint): cuint +proc eglGetConfigAttrib(display, config: pointer, attribute: cint, value: ptr cint): cuint +proc eglCreateContext(display, config, share: pointer, attributes: ptr cint): pointer +proc eglCreateWindowSurface(display, config, window: pointer, attributes: pointer): pointer +proc eglMakeCurrent(display, draw, read, context: pointer): cuint +proc eglSwapInterval(display: pointer, interval: cint): cuint +proc eglSwapBuffers(display, surface: pointer): cuint +proc eglDestroyContext(display, context: pointer): cuint +proc eglDestroySurface(display, surface: pointer): cuint +proc eglTerminate(display: pointer): cuint +proc eglGetError(): cuint +{.pop.} + +{.push cdecl, importc, dynlib: "libGLESv2.so.2".} +proc glGetString(name: cuint): cstring +proc glCreateShader(kind: cuint): cuint +proc glShaderSource(shader: cuint, count: cint, source: ptr cstring, lengths: pointer) +proc glCompileShader(shader: cuint) +proc glGetShaderiv(shader, name: cuint, value: ptr cint) +proc glGetShaderInfoLog(shader: cuint, size: cint, length: ptr cint, log: cstring) +proc glDeleteShader(shader: cuint) +proc glCreateProgram(): cuint +proc glAttachShader(program, shader: cuint) +proc glBindAttribLocation(program, index: cuint, name: cstring) +proc glLinkProgram(program: cuint) +proc glGetProgramiv(program, name: cuint, value: ptr cint) +proc glGetProgramInfoLog(program: cuint, size: cint, length: ptr cint, log: cstring) +proc glDeleteProgram(program: cuint) +proc glUseProgram(program: cuint) +proc glGetUniformLocation(program: cuint, name: cstring): cint +proc glUniform1f(location: cint, value: cfloat) +proc glUniform1i(location, value: cint) +proc glGenTextures(count: cint, textures: ptr cuint) +proc glDeleteTextures(count: cint, textures: ptr cuint) +proc glBindTexture(target, texture: cuint) +proc glTexParameteri(target, name: cuint, value: cint) +proc glTexImage2D(target: cuint, level, internal, width, height, border: cint, + format, kind: cuint, data: pointer) +proc glTexSubImage2D(target: cuint, level, x, y, width, height: cint, + format, kind: cuint, data: pointer) +proc glViewport(x, y, width, height: cint) +proc glClearColor(red, green, blue, alpha: cfloat) +proc glClear(mask: cuint) +proc glEnableVertexAttribArray(index: cuint) +proc glVertexAttribPointer(index: cuint, size: cint, kind: cuint, normalized: uint8, + stride: cint, data: pointer) +proc glDrawArrays(mode: cuint, first, count: cint) +proc glFinish() +proc glReadPixels(x, y, width, height: cint, format, kind: cuint, data: pointer) +proc glGetError(): cuint +{.pop.} + +proc checked(value: bool, operation: string) = + ## Fail with the operation and current system error instead of continuing. + if not value: raise newException(IOError, operation & ": " & $osLastError()) + +proc checkedEgl(value: cuint, operation: string) = + ## Include EGL's own error code when context or buffer operations fail. + if value == 0: raise newException(IOError, operation & ": EGL " & $eglGetError()) + +proc loadAtomic(display: DrmDisplay): AtomicApi = + ## Load experimental entry points without changing the normal DRM backend. + template bindField(field: untyped, name: string) = + result.field = cast[type(result.field)](symAddr(display.api.library, name)) + checked(result.field != nil, name) + bindField(alloc, "drmModeAtomicAlloc") + bindField(free, "drmModeAtomicFree") + bindField(add, "drmModeAtomicAddProperty") + bindField(commit, "drmModeAtomicCommit") + bindField(createBlob, "drmModeCreatePropertyBlob") + bindField(destroyBlob, "drmModeDestroyPropertyBlob") + +proc property(probe: Probe, id, kind: uint32, name: string): AtomicValue = + ## Find a property by name and retain its original value for restoration. + let + api = probe.display.api + values = api.getProperties(probe.display.fd, id, kind) + checked(values != nil, "Reading properties") + defer: api.freeProperties(values) + for index in 0 ..< int(values.count): + let item = api.getProperty(probe.display.fd, values.ids[index]) + checked(item != nil, "Reading property") + let matches = $cast[cstring](addr item.name[0]) == name + api.freeProperty(item) + if matches: + return AtomicValue(id: id, property: values.ids[index], value: values.values[index]) + raise newException(IOError, "Missing DRM property " & name & " on " & $id) + +proc set(probe: Probe, id, kind: uint32, name: string, value: uint64) = + ## Append an atomic update and the matching original state exactly once. + let previous = probe.property(id, kind, name) + probe.restore.add previous + probe.properties.add AtomicValue(id: id, property: previous.property, value: value) + +proc blob(probe: Probe, mode: var ModeInfo): uint32 = + ## Retain a mode blob until after the display has been restored. + checked(probe.atomic.createBlob(probe.display.fd, addr mode, csize_t(sizeof(mode)), addr result) == 0, + "Creating mode blob") + probe.blobs.add result + +proc configure(probe: Probe) = + ## Discover primary planes and construct one transaction for two active outputs. + let + display = probe.display + api = display.api + planes = api.getPlaneResources(display.fd) + resources = api.getResources(display.fd) + checked(planes != nil and resources != nil, "Enumerating planes") + defer: + api.freePlaneResources(planes) + api.freeResources(resources) + for index, screen in display.screens: + var crtcIndex = -1 + for candidate in 0 ..< int(resources.countCrtcs): + if resources.crtcs[candidate] == screen.crtc: crtcIndex = candidate + checked(crtcIndex >= 0, "Finding CRTC index") + for candidate in 0 ..< int(planes.count): + let plane = api.getPlane(display.fd, planes.planes[candidate]) + checked(plane != nil, "Reading plane") + defer: api.freePlane(plane) + if (plane.possibleCrtcs and (1'u32 shl crtcIndex)) == 0: continue + if probe.property(plane.id, PlaneObject, "type").value != PrimaryPlane: continue + if plane.crtc notin [0'u32, screen.crtc]: continue + if plane.id in probe.planes: continue + var compatible = false + for format in 0 ..< int(plane.countFormats): + if plane.formats[format] == Xrgb8888: compatible = true + if compatible: + probe.planes[index] = plane.id + break + checked(probe.planes[index] != 0, "Finding compatible primary plane") + let plane = probe.planes[index] + probe.pair.crtcs[index] = screen.crtc + probe.set(screen.connector, ConnectorObject, "CRTC_ID", screen.crtc) + probe.set(screen.crtc, CrtcObject, "ACTIVE", 1) + probe.set(screen.crtc, CrtcObject, "MODE_ID", probe.blob(screen.mode)) + # Copy an active original mode into our own blob instead of retaining its ID. + if screen.original.modeValid != 0: + probe.restore[^1].value = probe.blob(screen.original.mode) + probe.set(plane, PlaneObject, "CRTC_ID", screen.crtc) + probe.set(plane, PlaneObject, "FB_ID", 0) + probe.fbProperties[index] = probe.properties[^1].property + probe.set(plane, PlaneObject, "SRC_X", 0) + probe.set(plane, PlaneObject, "SRC_Y", uint64(index * probe.panelHeight) shl 16) + probe.set(plane, PlaneObject, "SRC_W", uint64(probe.panelWidth) shl 16) + probe.set(plane, PlaneObject, "SRC_H", uint64(probe.panelHeight) shl 16) + probe.set(plane, PlaneObject, "CRTC_X", 0) + probe.set(plane, PlaneObject, "CRTC_Y", 0) + probe.set(plane, PlaneObject, "CRTC_W", uint64(probe.panelWidth)) + probe.set(plane, PlaneObject, "CRTC_H", uint64(probe.panelHeight)) + echo screen.name, ": CRTC ", screen.crtc, ", plane ", plane + +proc commit(probe: Probe, values: seq[AtomicValue], flags: uint32, data: pointer = nil) = + ## Submit a complete transaction or leave the current state unchanged on error. + let request = probe.atomic.alloc() + checked(request != nil, "Allocating atomic request") + defer: probe.atomic.free(request) + for item in values: + checked(probe.atomic.add(request, item.id, item.property, item.value) >= 0, "Adding atomic property") + checked(probe.atomic.commit(probe.display.fd, request, flags, data) == 0, "Committing paired display update") + +proc flip2(fd: cint, sequence, seconds, microseconds, crtc: cuint, data: pointer) {.cdecl.} = + ## Handle both events without allowing a Nim exception across a C callback. + let probe = cast[Probe](data) + try: + probe.pair.complete(crtc, uint64(seconds) * 1_000_000 + uint64(microseconds)) + except ValueError as error: + probe.callbackError = error.msg + +proc waitPair(probe: Probe) = + ## Retire a generation only after both display controllers have completed it. + if not probe.pending: return + let started = getMonoTime() + var context = PairEvents(version: 3, flip2: flip2) + while not probe.pair.ready: + let remaining = FlipTimeoutMs - int((getMonoTime() - started).inMilliseconds) + checked(remaining > 0, "Timed out waiting for paired flip") + var descriptor = TPollfd(fd: probe.display.fd, events: POLLIN) + let polled = posix.poll(addr descriptor, 1, cint(remaining)) + if polled < 0 and osLastError() == OSErrorCode(EINTR): continue + checked(polled > 0 and (descriptor.revents and POLLIN) != 0, "Polling paired flip") + checked(probe.display.api.handleEvent(probe.display.fd, cast[ptr EventContext](addr context)) == 0, + "Reading paired flip") + if probe.callbackError.len > 0: raise newException(IOError, probe.callbackError) + probe.pending = false + let timestamps = probe.pair.timestamps + probe.skew.add abs(timestamps[0].float64 - timestamps[1].float64) / 1000 + +proc shader(kind: uint32, source: string): uint32 = + ## Compile a shader and report the driver's compiler log on failure. + result = glCreateShader(kind) + var + text = source.cstring + compiled: cint + glShaderSource(result, 1, addr text, nil) + glCompileShader(result) + glGetShaderiv(result, GlCompileStatus, addr compiled) + if compiled == 0: + var log = newString(4096) + glGetShaderInfoLog(result, cint(log.len), nil, log.cstring) + glDeleteShader(result) + raise newException(IOError, "Compiling shader: " & log) + +proc openGpu(probe: Probe) = + ## Create one EGL context and one linear GBM render target for both panels. + probe.gbm = gbm_create_device(probe.display.fd) + checked(probe.gbm != nil, "Creating GBM device") + var linear = 0'u64 + probe.surface = gbm_surface_create_with_modifiers2(probe.gbm, uint32(probe.panelWidth), + uint32(probe.panelHeight * 2), Xrgb8888, addr linear, 1, GbmScanoutRendering) + checked(probe.surface != nil, "Creating linear combined GBM surface") + probe.egl = eglGetPlatformDisplay(EglGbm, probe.gbm, nil) + checked(probe.egl != nil, "Opening GBM EGL display") + checkedEgl(eglInitialize(probe.egl, nil, nil), "Initializing EGL") + checkedEgl(eglBindAPI(EglEsApi), "Selecting OpenGL ES") + var + attributes = EglConfigAttributes + configs: array[64, pointer] + count: cint + selected: pointer + checkedEgl(eglChooseConfig(probe.egl, addr attributes[0], addr configs[0], 64, addr count), "Choosing EGL config") + for index in 0 ..< int(count): + var visual: cint + checkedEgl(eglGetConfigAttrib(probe.egl, configs[index], EglNativeVisual, addr visual), "Reading EGL visual") + if uint32(visual) == Xrgb8888: selected = configs[index] + checked(selected != nil, "Finding XRGB EGL config") + var contextAttributes = [EglClientVersion, 2.cint, EglNone] + probe.context = eglCreateContext(probe.egl, selected, nil, addr contextAttributes[0]) + checked(probe.context != nil, "Creating GLES2 context") + probe.eglSurface = eglCreateWindowSurface(probe.egl, selected, probe.surface, nil) + checked(probe.eglSurface != nil, "Creating EGL window surface") + checkedEgl(eglMakeCurrent(probe.egl, probe.eglSurface, probe.eglSurface, probe.context), "Making context current") + checkedEgl(eglSwapInterval(probe.egl, 0), "Disabling EGL's separate refresh wait") + let + renderer = $glGetString(GlRenderer) + version = $glGetString(GlVersion) + echo "GPU: ", renderer, "; GLES: ", version + checked(renderer.len > 0, "Expected a hardware renderer") + for software in ["llvmpipe", "softpipe", "swrast", "swiftshader"]: + checked(software notin renderer.toLowerAscii, "Software rendering is not a GPU proof: " & renderer) + let + vertex = shader(GlVertexShader, VertexSource) + fragment = shader(GlFragmentShader, FragmentSource) + defer: + glDeleteShader(vertex) + glDeleteShader(fragment) + probe.program = glCreateProgram() + glAttachShader(probe.program, vertex) + glAttachShader(probe.program, fragment) + glBindAttribLocation(probe.program, 0, "position") + glBindAttribLocation(probe.program, 1, "uv") + glLinkProgram(probe.program) + var linked: cint + glGetProgramiv(probe.program, GlLinkStatus, addr linked) + if linked == 0: + var log = newString(4096) + glGetProgramInfoLog(probe.program, cint(log.len), nil, log.cstring) + raise newException(IOError, "Linking program: " & log) + glUseProgram(probe.program) + glUniform1i(glGetUniformLocation(probe.program, "image"), 0) + glUniform1f(glGetUniformLocation(probe.program, "swapRB"), if probe.layout == xrgbPixels: 1 else: 0) + glGenTextures(1, addr probe.texture) + glBindTexture(GlTexture, probe.texture) + for name in [GlMagFilter, GlMinFilter]: glTexParameteri(GlTexture, name, GlNearest) + for name in [GlWrapS, GlWrapT]: glTexParameteri(GlTexture, name, GlClampEdge) + glTexImage2D(GlTexture, 0, cint(GlRgba), SourceWidth, cint(probe.sourceHeight * 2), 0, GlRgba, GlByte, nil) + glEnableVertexAttribArray(0) + glEnableVertexAttribArray(1) + +proc draw(probe: Probe) = + ## Enlarge two cropped texture regions with nearest-neighbour sampling. + glClearColor(0, 0, 0, 1) + glClear(GlColourBuffer) + let view = fit(SourceWidth, probe.sourceHeight, probe.panelWidth, probe.panelHeight) + for index in 0..1: + let + first = cfloat(index) * 0.5 + last = first + 0.5 + var vertices = [-1.cfloat, -1, 0, last, 1, -1, 1, last, -1, 1, 0, first, + -1, 1, 0, first, 1, -1, 1, last, 1, 1, 1, first] + glViewport(cint(view.x), cint((1 - index) * probe.panelHeight + view.y), cint(view.width), cint(view.height)) + glVertexAttribPointer(0, 2, GlFloat, 0, 16, addr vertices[0]) + glVertexAttribPointer(1, 2, GlFloat, 0, 16, addr vertices[2]) + glDrawArrays(GlTriangles, 0, 6) + glFinish() + checked(glGetError() == 0, "Drawing GPU pattern") + +proc readback(probe: Probe) = + ## Validate every rendered pixel, including borders, before touching scanout. + let + width = probe.panelWidth + height = probe.panelHeight * 2 + view = fit(SourceWidth, probe.sourceHeight, width, probe.panelHeight) + scale = view.width div SourceWidth + var output = newSeq[uint32](width * height) + glReadPixels(0, 0, cint(width), cint(height), GlRgba, GlByte, addr output[0]) + checked(glGetError() == 0, "Reading GPU pixels") + for y in 0 ..< height: + let localY = y mod probe.panelHeight + for x in 0 ..< width: + var expected = 0xff000000'u32 + if x >= view.x and x < view.x + view.width and localY >= view.y and localY < view.y + view.height: + expected = pixel((x - view.x) div scale, (localY - view.y) div scale + + (y div probe.panelHeight) * probe.sourceHeight, 0, probe.sourceHeight) + let actual = output[(height - 1 - y) * width + x] or 0xff000000'u32 + if actual != expected: + raise newException(IOError, &"GPU readback mismatch at {x},{y}: {actual} != {expected}") + echo "GPU readback: exact nearest-neighbour pixels and borders verified" + +proc framebuffer(probe: Probe, bo: pointer): uint32 = + ## Register each GBM allocation once and reject non-linear storage explicitly. + for frame in probe.gbmFrames: + if frame.bo == bo: return frame.fb + checked(gbm_bo_get_modifier(bo) == 0, "Expected a linear GBM buffer") + var + handles = [gbm_bo_get_handle(bo).u32, 0'u32, 0, 0] + pitches = [gbm_bo_get_stride(bo), 0'u32, 0, 0] + offsets: array[4, uint32] + checked(probe.display.api.addFb2(probe.display.fd, uint32(probe.panelWidth), + uint32(probe.panelHeight * 2), Xrgb8888, addr handles[0], addr pitches[0], + addr offsets[0], addr result, 0) == 0, "Registering combined GBM framebuffer") + probe.gbmFrames.add (bo, result) + +proc submit(probe: Probe, fb: uint32) = + ## Test the initial pair and then submit one vblank-synchronized transaction. + var values: seq[AtomicValue] + for item in probe.properties: + if probe.armed and item.property notin probe.fbProperties: continue + var value = item + if item.id in probe.planes: + for index in 0..1: + if item.id == probe.planes[index] and item.property == probe.fbProperties[index]: + value.value = fb + values.add value + if not probe.armed: + probe.commit(values, AtomicTest or AllowModeset) + echo "Atomic TEST_ONLY: combined framebuffer accepted by both outputs" + probe.commit(values, AllowModeset) + probe.armed = true + else: + probe.pair.completed = {} + probe.commit(values, AtomicNonblock or FlipEvent, cast[pointer](probe)) + probe.pending = true + +proc close(probe: Probe) = + ## Restore the saved transaction before freeing anything still used for scanout. + if probe.display == nil: return + var errors: seq[string] + template attempt(body: untyped) = + try: body + except CatchableError as error: errors.add error.msg + if probe.armed: + attempt: probe.waitPair() + attempt: + probe.commit(probe.restore, AllowModeset) + probe.armed = false + if probe.armed: + # A failed restore must not leave scanout pointing at freed GPU storage. + var disabled = true + for screen in probe.display.screens: + if probe.display.api.setCrtc(probe.display.fd, screen.crtc, 0, 0, 0, nil, 0, nil) < 0: + disabled = false + errors.add "Disabling output after failed restore: " & screen.name + if not disabled: + attempt: checked(posix.close(probe.display.fd) == 0, "Closing failed DRM session") + probe.display.fd = -1 + for screen in probe.display.screens: + for scanout in screen.buffers: + attempt: checked(munmap(scanout.memory, int(scanout.buffer.size)) == 0, "Unmapping abandoned CPU buffer") + screen.buffers.setLen(0) + for bo in probe.locked: gbm_surface_release_buffer(probe.surface, bo) + probe.locked.setLen(0) + for frame in probe.gbmFrames: + if probe.display.fd >= 0: + attempt: checked(probe.display.api.removeFb(probe.display.fd, frame.fb) == 0, "Removing GPU framebuffer") + if probe.egl != nil: + if probe.context != nil: + if probe.texture != 0: glDeleteTextures(1, addr probe.texture) + if probe.program != 0: glDeleteProgram(probe.program) + attempt: checkedEgl(eglMakeCurrent(probe.egl, nil, nil, nil), "Releasing EGL context") + if probe.eglSurface != nil: + attempt: checkedEgl(eglDestroySurface(probe.egl, probe.eglSurface), "Destroying EGL surface") + if probe.context != nil: + attempt: checkedEgl(eglDestroyContext(probe.egl, probe.context), "Destroying EGL context") + attempt: checkedEgl(eglTerminate(probe.egl), "Terminating EGL") + if probe.surface != nil: gbm_surface_destroy(probe.surface) + if probe.gbm != nil: gbm_device_destroy(probe.gbm) + if probe.display.fd >= 0: + for blob in probe.blobs: + attempt: checked(probe.atomic.destroyBlob(probe.display.fd, blob) == 0, "Destroying mode blob") + attempt: probe.display.close() + probe.display = nil + if errors.len > 0: raise newException(IOError, errors.join("; ")) + +proc elapsed(started: MonoTime): float64 = + ## Measure an operation in milliseconds using the monotonic clock. + (getMonoTime() - started).inNanoseconds.float64 / 1_000_000 + +proc report(name: string, samples: seq[float64]) = + ## Print mean, tail and peak rather than hiding hitches behind average FPS. + if samples.len == 0: return + var + ordered = samples.sorted() + total = 0.0 + for sample in samples: total += sample + echo name, " ms mean/p95/max: ", formatFloat(total / samples.len.float64, ffDecimal, 3), "/", + formatFloat(ordered[min(ordered.high, ordered.len * 95 div 100)], ffDecimal, 3), "/", + formatFloat(ordered[^1], ffDecimal, 3) + +proc main() = + ## Run an explicitly selected, bounded diagnostic without opening game data. + if paramCount() notin [6, 7]: + raise newException(ValueError, "Usage: manual_drm_gpu CARD TOP BOTTOM ds|earthbound xbgr|xrgb gpu|cpu|inspect [seconds<=8]") + if paramStr(4) notin ["ds", "earthbound"] or paramStr(5) notin ["xbgr", "xrgb"] or + paramStr(6) notin ["gpu", "cpu", "inspect"]: + raise newException(ValueError, "Unknown layout, format or mode") + var seconds = 8 + if paramCount() == 7: + let value = paramStr(7) + if parseInt(value, seconds) != value.len or seconds notin 1..8: + raise newException(ValueError, "Duration must be 1..8 seconds") + let probe = Probe(sourceHeight: (if paramStr(4) == "ds": 192 else: 224), + layout: (if paramStr(5) == "xbgr": xbgrPixels else: xrgbPixels)) + defer: probe.close() + probe.display = openDrm(paramStr(1)) + probe.display.openOutput(@[paramStr(2), paramStr(3)], 0, vsync = true) + let first = probe.display.screens[0] + probe.panelWidth = first.width + probe.panelHeight = first.height + let second = probe.display.screens[1] + checked(first.width == second.width and first.height == second.height, "Expected equal panel sizes") + checked(first.crtc != second.crtc, "Expected independent display controllers") + probe.atomic = loadAtomic(probe.display) + checked(probe.display.api.setClientCap(probe.display.fd, AtomicCapability, 1) == 0, "Enabling atomic KMS") + probe.configure() + generate(probe.pixels, probe.sourceHeight, 0, probe.layout) + if paramStr(6) != "cpu": + probe.openGpu() + glTexSubImage2D(GlTexture, 0, 0, 0, SourceWidth, cint(probe.sourceHeight * 2), GlRgba, GlByte, addr probe.pixels[0]) + probe.draw() + probe.readback() + if paramStr(6) == "inspect": return + checked(probe.display.api.setMaster(probe.display.fd) == 0, "Acquiring DRM master") + probe.display.master = true + var + timings: Samples + frames = 0 + late = 0 + images = [newImage(probe.panelWidth, probe.panelHeight), newImage(probe.panelWidth, probe.panelHeight)] + let + started = getMonoTime() + cpuStarted = cpuTime() + while (getMonoTime() - started).inMilliseconds < seconds * 1000: + let frameStarted = getMonoTime() + var step = getMonoTime() + generate(probe.pixels, probe.sourceHeight, frames, probe.layout) + timings.generate.add elapsed(step) + if paramStr(6) == "gpu": + step = getMonoTime() + glTexSubImage2D(GlTexture, 0, 0, 0, SourceWidth, cint(probe.sourceHeight * 2), GlRgba, GlByte, addr probe.pixels[0]) + timings.upload.add elapsed(step) + step = getMonoTime() + probe.draw() + checkedEgl(eglSwapBuffers(probe.egl, probe.eglSurface), "Swapping EGL render buffer") + let bo = gbm_surface_lock_front_buffer(probe.surface) + checked(bo != nil, "Locking completed GBM buffer") + probe.locked.add bo + timings.render.add elapsed(step) + step = getMonoTime() + probe.submit(probe.framebuffer(bo)) + timings.submit.add elapsed(step) + step = getMonoTime() + probe.waitPair() + timings.wait.add elapsed(step) + if probe.front != nil: + gbm_surface_release_buffer(probe.surface, probe.front) + probe.locked.delete(probe.locked.find(probe.front)) + probe.front = bo + else: + step = getMonoTime() + for index in 0..1: + scalePanel(probe.pixels, index, probe.sourceHeight, probe.layout, images[index].data, + probe.panelWidth, probe.panelHeight) + timings.render.add elapsed(step) + step = getMonoTime() + for index in 0..1: + probe.display.present(index, images[index]) + timings.submit.add elapsed(step) + step = getMonoTime() + for screen in probe.display.screens: probe.display.waitForFlip(screen) + timings.wait.add elapsed(step) + inc frames + if elapsed(frameStarted) > 20: inc late + echo "Mode: ", paramStr(6), "; layout: ", paramStr(4), "; format: ", paramStr(5), + "; frames: ", frames, "; elapsed ms: ", elapsed(started), "; frames over 20 ms: ", late + echo "Process CPU ms/frame: ", (cpuTime() - cpuStarted) * 1000 / frames.float64 + report("Generate", timings.generate) + report("Upload", timings.upload) + report("Render/finish", timings.render) + report("Submit", timings.submit) + report("Wait", timings.wait) + report("Panel completion skew", probe.skew) + +when isMainModule: + main() diff --git a/tests/test_drm_gpu_patterns.nim b/tests/test_drm_gpu_patterns.nim new file mode 100644 index 00000000..8c867fcf --- /dev/null +++ b/tests/test_drm_gpu_patterns.nim @@ -0,0 +1,50 @@ +import + ./drm_gpu_patterns + +proc main() = + ## Check layout, colour contracts and both possible completion orders. + for height in [192, 224]: + let view = fit(SourceWidth, height, 1024, 768) + if height == 192: + doAssert view == Viewport(x: 0, y: 0, width: 1024, height: 768) + else: + doAssert view == Viewport(x: 128, y: 48, width: 768, height: 672) + var xbgr, xrgb: seq[uint32] + generate(xbgr, height, 7, xbgrPixels) + generate(xrgb, height, 7, xrgbPixels) + doAssert xbgr.len == SourceWidth * height * 2 + for index, colour in xbgr: + doAssert colour shr 24 == 255 + doAssert pack(xrgb[index], xrgbPixels) == colour + doAssert pixel(1, height - 1, 7, height) != pixel(1, height * 2 - 1, 7, height) + var output: seq[uint32] + for layout in [xbgrPixels, xrgbPixels]: + for panel in 0..1: + scalePanel((if layout == xbgrPixels: xbgr else: xrgb), panel, height, layout, + output, 1024, 768) + for y in 0..<768: + for x in 0..<1024: + var expected = 0xff000000'u32 + if x >= view.x and x < view.x + view.width and y >= view.y and y < view.y + view.height: + expected = pixel((x - view.x) div (view.width div SourceWidth), + (y - view.y) div (view.width div SourceWidth) + panel * height, 7, height) + doAssert output[y * 1024 + x] == expected + for order in [[0, 1], [1, 0]]: + var pair = PairedFlip(crtcs: [69'u32, 70]) + pair.complete(pair.crtcs[order[0]], 100) + doAssert not pair.ready + pair.complete(pair.crtcs[order[1]], 105) + doAssert pair.ready + doAssert pair.timestamps[order[0]] == 100 + doAssert pair.timestamps[order[1]] == 105 + for crtc in [pair.crtcs[0], 999'u32]: + var rejected = false + try: pair.complete(crtc, 110) + except ValueError: rejected = true + doAssert rejected + var rejected = false + try: discard fit(2048, 768, 1024, 768) + except ValueError: rejected = true + doAssert rejected + +main() From 389055df0c46e99613dd4326e66af3d0e91c935c Mon Sep 17 00:00:00 2001 From: monofuel Date: Thu, 29 Oct 2026 22:11:03 -0400 Subject: [PATCH 07/21] Enumerate all EGL configs for the requested scanout format --- docs/drm-gpu-proof.md | 2 +- tests/manual_drm_gpu.nim | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/drm-gpu-proof.md b/docs/drm-gpu-proof.md index 2686e8e3..9f923ed5 100644 --- a/docs/drm-gpu-proof.md +++ b/docs/drm-gpu-proof.md @@ -15,7 +15,7 @@ Nim dependency is needed. manual_drm_gpu CARD TOP_CONNECTOR BOTTOM_CONNECTOR ds|earthbound xbgr|xrgb gpu|cpu|inspect [seconds] ``` -`inspect` initializes EGL, renders and checks pixels, but never acquires DRM +`inspect` initializes EGL, renders and checks pixels, but never requests DRM master or changes scanout. Other modes temporarily own both displays. Stop the display service before running them; use an outer timeout and restore the service on exit. Duration defaults to eight seconds and accepts only 1..8. Initialization diff --git a/tests/manual_drm_gpu.nim b/tests/manual_drm_gpu.nim index 0bcd86c7..6f3b5b03 100644 --- a/tests/manual_drm_gpu.nim +++ b/tests/manual_drm_gpu.nim @@ -327,10 +327,12 @@ proc openGpu(probe: Probe) = checkedEgl(eglBindAPI(EglEsApi), "Selecting OpenGL ES") var attributes = EglConfigAttributes - configs: array[64, pointer] count: cint selected: pointer - checkedEgl(eglChooseConfig(probe.egl, addr attributes[0], addr configs[0], 64, addr count), "Choosing EGL config") + checkedEgl(eglChooseConfig(probe.egl, addr attributes[0], nil, 0, addr count), "Counting EGL configs") + checked(count > 0, "Expected a window-compatible EGL config") + var configs = newSeq[pointer](count) + checkedEgl(eglChooseConfig(probe.egl, addr attributes[0], addr configs[0], count, addr count), "Choosing EGL config") for index in 0 ..< int(count): var visual: cint checkedEgl(eglGetConfigAttrib(probe.egl, configs[index], EglNativeVisual, addr visual), "Reading EGL visual") From 0047e15a0adf63c22ed422cff610590758f30821 Mon Sep 17 00:00:00 2001 From: monofuel Date: Thu, 29 Oct 2026 22:53:05 -0400 Subject: [PATCH 08/21] Finish GPU work after EGL resolves the presentation buffer --- tests/manual_drm_gpu.nim | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/manual_drm_gpu.nim b/tests/manual_drm_gpu.nim index 6f3b5b03..d3d181ef 100644 --- a/tests/manual_drm_gpu.nim +++ b/tests/manual_drm_gpu.nim @@ -396,7 +396,6 @@ proc draw(probe: Probe) = glVertexAttribPointer(0, 2, GlFloat, 0, 16, addr vertices[0]) glVertexAttribPointer(1, 2, GlFloat, 0, 16, addr vertices[2]) glDrawArrays(GlTriangles, 0, 6) - glFinish() checked(glGetError() == 0, "Drawing GPU pattern") proc readback(probe: Probe) = @@ -576,6 +575,9 @@ proc main() = step = getMonoTime() probe.draw() checkedEgl(eglSwapBuffers(probe.egl, probe.eglSurface), "Swapping EGL render buffer") + # Include any resolve queued by EGL's swap before exposing the BO to KMS. + glFinish() + checked(glGetError() == 0, "Finishing GPU presentation") let bo = gbm_surface_lock_front_buffer(probe.surface) checked(bo != nil, "Locking completed GBM buffer") probe.locked.add bo From d260ef88c40c26cca3948dbb1d07ad6ae8a3e592 Mon Sep 17 00:00:00 2001 From: monofuel Date: Thu, 29 Oct 2026 22:58:44 -0400 Subject: [PATCH 09/21] Keep synthetic quad vertices in a GLES buffer --- tests/manual_drm_gpu.nim | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/tests/manual_drm_gpu.nim b/tests/manual_drm_gpu.nim index d3d181ef..607a2c55 100644 --- a/tests/manual_drm_gpu.nim +++ b/tests/manual_drm_gpu.nim @@ -37,7 +37,7 @@ type armed, pending: bool callbackError: string gbm, surface, egl, context, eglSurface: pointer - program, texture: uint32 + program, texture, vertices: uint32 gbmFrames: seq[tuple[bo: pointer, fb: uint32]] locked: seq[pointer] front: pointer @@ -81,6 +81,10 @@ const AtomicCapability = 3'u64 PrimaryPlane = 1'u64 GbmScanoutRendering = 5'u32 + GlArrayBuffer = 0x8892'u32 + GlStaticDraw = 0x88e4'u32 + VerticesPerPanel = 6 + VertexStride = 4 * sizeof(cfloat) EglNone = 0x3038.cint EglConfigAttributes = [0x3033.cint, 4, 0x3040, 4, 0x3024, 8, 0x3023, 8, 0x3022, 8, 0x3025, 0, 0x3026, 0, EglNone] @@ -144,6 +148,10 @@ proc glUniform1f(location: cint, value: cfloat) proc glUniform1i(location, value: cint) proc glGenTextures(count: cint, textures: ptr cuint) proc glDeleteTextures(count: cint, textures: ptr cuint) +proc glGenBuffers(count: cint, buffers: ptr cuint) +proc glBindBuffer(target, buffer: cuint) +proc glBufferData(target: cuint, size: int, data: pointer, usage: cuint) +proc glDeleteBuffers(count: cint, buffers: ptr cuint) proc glBindTexture(target, texture: cuint) proc glTexParameteri(target, name: cuint, value: cint) proc glTexImage2D(target: cuint, level, internal, width, height, border: cint, @@ -380,6 +388,16 @@ proc openGpu(probe: Probe) = glTexImage2D(GlTexture, 0, cint(GlRgba), SourceWidth, cint(probe.sourceHeight * 2), 0, GlRgba, GlByte, nil) glEnableVertexAttribArray(0) glEnableVertexAttribArray(1) + var vertices: seq[cfloat] + for index in 0..1: + let + first = cfloat(index) * 0.5 + last = first + 0.5 + vertices.add @[-1.cfloat, -1, 0, last, 1, -1, 1, last, -1, 1, 0, first, + -1, 1, 0, first, 1, -1, 1, last, 1, 1, 1, first] + glGenBuffers(1, addr probe.vertices) + glBindBuffer(GlArrayBuffer, probe.vertices) + glBufferData(GlArrayBuffer, vertices.len * sizeof(cfloat), addr vertices[0], GlStaticDraw) proc draw(probe: Probe) = ## Enlarge two cropped texture regions with nearest-neighbour sampling. @@ -387,15 +405,11 @@ proc draw(probe: Probe) = glClear(GlColourBuffer) let view = fit(SourceWidth, probe.sourceHeight, probe.panelWidth, probe.panelHeight) for index in 0..1: - let - first = cfloat(index) * 0.5 - last = first + 0.5 - var vertices = [-1.cfloat, -1, 0, last, 1, -1, 1, last, -1, 1, 0, first, - -1, 1, 0, first, 1, -1, 1, last, 1, 1, 1, first] + let offset = index * VerticesPerPanel * VertexStride glViewport(cint(view.x), cint((1 - index) * probe.panelHeight + view.y), cint(view.width), cint(view.height)) - glVertexAttribPointer(0, 2, GlFloat, 0, 16, addr vertices[0]) - glVertexAttribPointer(1, 2, GlFloat, 0, 16, addr vertices[2]) - glDrawArrays(GlTriangles, 0, 6) + glVertexAttribPointer(0, 2, GlFloat, 0, VertexStride, cast[pointer](offset)) + glVertexAttribPointer(1, 2, GlFloat, 0, VertexStride, cast[pointer](offset + 2 * sizeof(cfloat))) + glDrawArrays(GlTriangles, 0, VerticesPerPanel) checked(glGetError() == 0, "Drawing GPU pattern") proc readback(probe: Probe) = @@ -489,6 +503,7 @@ proc close(probe: Probe) = if probe.egl != nil: if probe.context != nil: if probe.texture != 0: glDeleteTextures(1, addr probe.texture) + if probe.vertices != 0: glDeleteBuffers(1, addr probe.vertices) if probe.program != 0: glDeleteProgram(probe.program) attempt: checkedEgl(eglMakeCurrent(probe.egl, nil, nil, nil), "Releasing EGL context") if probe.eglSurface != nil: From 627c7aa64c9571efff504f83febe91aa4e66b409 Mon Sep 17 00:00:00 2001 From: monofuel Date: Thu, 29 Oct 2026 23:04:55 -0400 Subject: [PATCH 10/21] Release unused CPU scanout buffers before the GPU proof --- tests/manual_drm_gpu.nim | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/manual_drm_gpu.nim b/tests/manual_drm_gpu.nim index 607a2c55..a5bd9ba9 100644 --- a/tests/manual_drm_gpu.nim +++ b/tests/manual_drm_gpu.nim @@ -563,6 +563,10 @@ proc main() = probe.configure() generate(probe.pixels, probe.sourceHeight, 0, probe.layout) if paramStr(6) != "cpu": + # Retain discovery metadata without reserving unused CPU scanout storage. + for screen in probe.display.screens: + probe.display.closeScreen(screen) + screen.buffers.setLen(0) probe.openGpu() glTexSubImage2D(GlTexture, 0, 0, 0, SourceWidth, cint(probe.sourceHeight * 2), GlRgba, GlByte, addr probe.pixels[0]) probe.draw() From ad67736e9a6f178e6812aa2b154b4672a13f60d3 Mon Sep 17 00:00:00 2001 From: monofuel Date: Thu, 29 Oct 2026 23:07:06 -0400 Subject: [PATCH 11/21] Match the CPU reference scaler loop specialization --- tests/drm_gpu_patterns.nim | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/drm_gpu_patterns.nim b/tests/drm_gpu_patterns.nim index aa857be4..22564c29 100644 --- a/tests/drm_gpu_patterns.nim +++ b/tests/drm_gpu_patterns.nim @@ -80,8 +80,14 @@ proc scalePanel*[T](source: seq[uint32], panel, sourceHeight: int, layout: Pixel for x in view.x + view.width ..< width: output[y * width + x] = cast[T](0xff000000'u32) for sourceY in 0 ..< sourceHeight: let target = (view.y + sourceY * scale) * width + view.x - for sourceX in 0 ..< SourceWidth: - let colour = pack(source[(panel * sourceHeight + sourceY) * SourceWidth + sourceX], layout) - for repeat in 0 ..< scale: output[target + sourceX * scale + repeat] = cast[T](colour) + template expandRow(factor: int) = + for sourceX in 0 ..< SourceWidth: + let colour = pack(source[(panel * sourceHeight + sourceY) * SourceWidth + sourceX], layout) + for repeat in 0 ..< factor: output[target + sourceX * factor + repeat] = cast[T](colour) + case scale + of 2: expandRow(2) + of 3: expandRow(3) + of 4: expandRow(4) + else: expandRow(scale) for repeat in 1 ..< scale: copyMem(addr output[target + repeat * width], addr output[target], view.width * sizeof(uint32)) From b39f864889a10dc132ae901d028ba4255f053d90 Mon Sep 17 00:00:00 2001 From: monofuel Date: Fri, 30 Oct 2026 00:11:17 -0400 Subject: [PATCH 12/21] Select unmultisampled EGL buffers and record DS Plus GPU results --- docs/drm-gpu-proof.md | 48 ++++++++++++++++++++++++++++++++++++---- tests/manual_drm_gpu.nim | 31 +++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/docs/drm-gpu-proof.md b/docs/drm-gpu-proof.md index 9f923ed5..7e06b8ed 100644 --- a/docs/drm-gpu-proof.md +++ b/docs/drm-gpu-proof.md @@ -28,6 +28,8 @@ Do not run beside games, compiler jobs or other performance tests. opaque R,G,B,255 bytes; XRGB uploads B,G,R,255. A shader handles the latter's channel interpretation without changing the source buffer on the CPU. - One linear XRGB GBM surface contains two full-resolution output regions. + EGL selection requires zero depth, stencil, samples and sample buffers; + `eglChooseConfig` minimum sizes alone do not exclude multisampling. Nearest-neighbour quads enlarge each source region by an integer and clear the remaining border to black. The `ds` layout uses 256×192 per panel; `earthbound` uses 256×224. Panel dimensions come from DRM, not board identifiers. @@ -57,7 +59,8 @@ Unchanged-panel caching is intentionally absent because the pattern moves. Reports include generation, upload, rendering/finish, submission and completion waits, mean/p95/max, process CPU time per frame, frames over 20 ms and paired -completion skew. CPU submission includes full-panel uploads. GPU rendering +completion skew. GPU rendering also separates draw calls, EGL swap, GL finish +and GBM acquisition. CPU submission includes full-panel uploads. GPU rendering includes EGL swap/GBM acquisition. Never compare the two labels as isolated equivalent operations; compare total process CPU and the complete pipeline. @@ -71,7 +74,44 @@ Run `nim r tests/test_drm_gpu_patterns.nim` for colours, both layouts, CPU outpu both completion orders and rejected duplicate/unexpected events. Existing `tests/test_linux_drm.nim` covers the unchanged CPU DRM lifecycle. -## Device results +## RG DS Plus results -Pending physical validation. A successful build or advertised DRM format does -not establish hardware rendering, paired scanout acceptance or a performance win. +Tested with Linux `7.0.2-mono-rgdsplus-rocknix-r3`, Mesa 26.1.8 and hardware +`Mali-G52 r1 MC1 (Panfrost)`, OpenGL ES 3.1. DSI-2 is top (CRTC70/plane39), +DSI-1 bottom (CRTC69/plane33). No X11 or Wayland is involved. Gentoo compiled +Mesa through the MLE `arm64-rgdsplus` distcc target; memory-heavy local source +generation needed temporary swap, removed before these measurements. + +Every GPU pixel and black border passes exact readback for both XRGB and XBGR +in both layouts. One shared 1024×1536 linear framebuffer passes atomic TEST_ONLY +and real paired flips. Both CRTC events arrive and cleanup restores the launcher. +The synthetic animation and all tests are silent. Manual confirmation of physical +orientation, perceived crispness and tearing is deferred. + +Matched eight-second runs with the same shipped ARM64 diagnostic: + +| Source per panel / input | CPU reference CPU ms/frame | GPU CPU ms/frame | CPU / GPU frames | CPU / GPU frames over 20 ms | +| --- | ---: | ---: | ---: | ---: | +| 256×192 / XBGR, 4× full panel | 7.605 | 1.935 | 481 / 480 | 1 / 2 | +| 256×224 / XBGR, 3× with borders | 7.603 | 2.118 | 481 / 481 | 2 / 2 | + +All runs are approximately 60 FPS. CPU time includes synthetic frame generation; +GPU presentation reduces that total by roughly 72–75%. These are presentation +tests, not emulator benchmarks or measurements of player integration. Initial +activation is included; no warmup frames are discarded. + +GPU upload averages 0.256–0.300 ms and render/swap/finish about 5.0–5.1 ms, +mostly GPU completion wait. XRGB also passes real paired scanout in both layouts +at approximately 60 FPS, with 2.063 / 2.251 CPU ms/frame respectively. Completion +timestamp skew settles around 6.1 ms: the panels show one coordinated generation, +but their physical refresh phases remain offset. + +The first configuration selected the last matching EGL visual, which had 24-bit +depth, 8-bit stencil and 16× MSAA. GPU completion then took about 78 ms and output +ran near 11 FPS despite 800 MHz GPU clocks. Explicitly selecting zero-sized +auxiliary buffers removes that work. The diagnostic reports the selected sizes +and fails if that configuration is unavailable. + +The production Windy backends and player remain unchanged. Next integration must +preserve frame ownership, both completion events, exact pixel scaling and X11/ +fbdev behavior; conservative `glFinish` can be revisited separately. diff --git a/tests/manual_drm_gpu.nim b/tests/manual_drm_gpu.nim index a5bd9ba9..4ba3d4b2 100644 --- a/tests/manual_drm_gpu.nim +++ b/tests/manual_drm_gpu.nim @@ -47,6 +47,7 @@ type skew: seq[float64] Samples = object generate, upload, render, submit, wait: seq[float64] + draw, swap, finish, acquire: seq[float64] const PlaneObject = 0xeeeeeeee'u32 @@ -78,6 +79,10 @@ const EglEsApi = 0x30a0'u32 EglNativeVisual = 0x302e.cint EglClientVersion = 0x3098.cint + EglDepthSize = 0x3025.cint + EglStencilSize = 0x3026.cint + EglSamples = 0x3031.cint + EglSampleBuffers = 0x3032.cint AtomicCapability = 3'u64 PrimaryPlane = 1'u64 GbmScanoutRendering = 5'u32 @@ -339,13 +344,22 @@ proc openGpu(probe: Probe) = selected: pointer checkedEgl(eglChooseConfig(probe.egl, addr attributes[0], nil, 0, addr count), "Counting EGL configs") checked(count > 0, "Expected a window-compatible EGL config") - var configs = newSeq[pointer](count) + var + configs = newSeq[pointer](count) + sizes: array[4, cint] checkedEgl(eglChooseConfig(probe.egl, addr attributes[0], addr configs[0], count, addr count), "Choosing EGL config") for index in 0 ..< int(count): var visual: cint checkedEgl(eglGetConfigAttrib(probe.egl, configs[index], EglNativeVisual, addr visual), "Reading EGL visual") - if uint32(visual) == Xrgb8888: selected = configs[index] - checked(selected != nil, "Finding XRGB EGL config") + if uint32(visual) != Xrgb8888: continue + for field, name in [EglDepthSize, EglStencilSize, EglSamples, EglSampleBuffers]: + checkedEgl(eglGetConfigAttrib(probe.egl, configs[index], name, addr sizes[field]), "Reading EGL buffers") + # EGL's zero attributes specify minimum sizes, not the absence of MSAA/depth. + if sizes == [0.cint, 0, 0, 0]: + selected = configs[index] + break + checked(selected != nil, "Finding XRGB EGL config without depth, stencil or MSAA") + echo "EGL depth/stencil/samples/sample buffers: ", sizes var contextAttributes = [EglClientVersion, 2.cint, EglNone] probe.context = eglCreateContext(probe.egl, selected, nil, addr contextAttributes[0]) checked(probe.context != nil, "Creating GLES2 context") @@ -593,13 +607,20 @@ proc main() = timings.upload.add elapsed(step) step = getMonoTime() probe.draw() + timings.draw.add elapsed(step) + var gpuStep = getMonoTime() checkedEgl(eglSwapBuffers(probe.egl, probe.eglSurface), "Swapping EGL render buffer") + timings.swap.add elapsed(gpuStep) # Include any resolve queued by EGL's swap before exposing the BO to KMS. + gpuStep = getMonoTime() glFinish() checked(glGetError() == 0, "Finishing GPU presentation") + timings.finish.add elapsed(gpuStep) + gpuStep = getMonoTime() let bo = gbm_surface_lock_front_buffer(probe.surface) checked(bo != nil, "Locking completed GBM buffer") probe.locked.add bo + timings.acquire.add elapsed(gpuStep) timings.render.add elapsed(step) step = getMonoTime() probe.submit(probe.framebuffer(bo)) @@ -632,6 +653,10 @@ proc main() = report("Generate", timings.generate) report("Upload", timings.upload) report("Render/finish", timings.render) + report("Draw calls", timings.draw) + report("EGL swap", timings.swap) + report("GL finish", timings.finish) + report("GBM acquire", timings.acquire) report("Submit", timings.submit) report("Wait", timings.wait) report("Panel completion skew", probe.skew) From 014cc1616302c295447fbcd8e0ae5a6656999260 Mon Sep 17 00:00:00 2001 From: monofuel Date: Fri, 30 Oct 2026 00:41:47 -0400 Subject: [PATCH 13/21] Confirm manual acceptance of both GPU display layouts --- docs/drm-gpu-proof.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/drm-gpu-proof.md b/docs/drm-gpu-proof.md index 7e06b8ed..5e5242c3 100644 --- a/docs/drm-gpu-proof.md +++ b/docs/drm-gpu-proof.md @@ -85,8 +85,9 @@ generation needed temporary swap, removed before these measurements. Every GPU pixel and black border passes exact readback for both XRGB and XBGR in both layouts. One shared 1024×1536 linear framebuffer passes atomic TEST_ONLY and real paired flips. Both CRTC events arrive and cleanup restores the launcher. -The synthetic animation and all tests are silent. Manual confirmation of physical -orientation, perceived crispness and tearing is deferred. +The synthetic animation and all tests are silent. Monofuel manually confirms +both full-panel 4× and bordered 3× patterns look correct: upright, crisp, correct +screen order, smooth markers and no visible moving tears. Matched eight-second runs with the same shipped ARM64 diagnostic: From 37f8d12c72c2094d6d2e44b325c2a66007d0e276 Mon Sep 17 00:00:00 2001 From: monofuel Date: Thu, 29 Oct 2026 16:06:50 -0400 Subject: [PATCH 14/21] Emit X11 wheel scrolling only on button press --- src/windy/platforms/linux/x11.nim | 7 +- tests/test_x11_wheel.nim | 112 ++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 tests/test_x11_wheel.nim diff --git a/src/windy/platforms/linux/x11.nim b/src/windy/platforms/linux/x11.nim index 46c09ab0..dd0a6233 100644 --- a/src/windy/platforms/linux/x11.nim +++ b/src/windy/platforms/linux/x11.nim @@ -1063,9 +1063,10 @@ proc pollEvents(window: Window) = of xeButtonPress, xeButtonRelease: template pushScrollEvent(delta: Vec2) = - window.perFrame.scrollDelta = delta - if window.onScroll != nil: - window.onScroll() + if ev.kind == xeButtonPress: + window.perFrame.scrollDelta += delta + if window.onScroll != nil: + window.onScroll() let now = getTime() diff --git a/tests/test_x11_wheel.nim b/tests/test_x11_wheel.nim new file mode 100644 index 00000000..3ab56197 --- /dev/null +++ b/tests/test_x11_wheel.nim @@ -0,0 +1,112 @@ +when defined(linux): + import + std/[os, osproc, streams, strutils], + windy, + windy/platforms/linux/x11/[x, xevent, xlib] + + type XWindow = x.Window + + const + WindowTitle = "Windy X11 wheel regression" + WheelUp = 4.cuint + WheelDown = 5.cuint + WheelLeft = 6.cuint + WheelRight = 7.cuint + WheelDelta = 10'f32 + + proc queryTree(display: Display, window: XWindow, root, parent: ptr XWindow, + children: ptr ptr UncheckedArray[XWindow], count: ptr cuint): cint + {.cdecl, importc: "XQueryTree", dynlib: libX11.} + proc fetchName(display: Display, window: XWindow, name: ptr cstring): cint + {.cdecl, importc: "XFetchName", dynlib: libX11.} + proc sendEvent(display: Display, window: XWindow, propagate: cint, mask: clong, + event: ptr XEvent): cint {.cdecl, importc: "XSendEvent", dynlib: libX11.} + proc closeDisplay(display: Display): cint + {.cdecl, importc: "XCloseDisplay", dynlib: libX11.} + proc sync(display: Display, discardEvents: cint): cint + {.cdecl, importc: "XSync", dynlib: libX11.} + + proc findWindow(display: Display): XWindow = + ## Locate only the newly created Windy window on the isolated X server. + var + root, parent: XWindow + children: ptr UncheckedArray[XWindow] + count: cuint + doAssert queryTree(display, display.defaultRootWindow, addr root, addr parent, + addr children, addr count) != 0 + defer: + if children != nil: XFree(children) + for index in 0 ..< count.int: + var name: cstring + if fetchName(display, children[index], addr name) != 0 and name != nil: + let matches = $name == WindowTitle + XFree(name) + if matches: return children[index] + raise newException(AssertionDefect, "Regression window was not created.") + + proc wheel(display: Display, window: XWindow, button: cuint, pressed: bool) = + ## Queue one authentic X11 wheel edge for Windy's public callback path. + var event = XEvent(button: XButtonEvent( + kind: if pressed: xeButtonPress else: xeButtonRelease, + display: display, window: window, root: display.defaultRootWindow, + button: button, same_screen: 1)) + doAssert sendEvent(display, window, 0, if pressed: 4 else: 8, addr event) != 0 + + proc main() = + ## Verify press-only callbacks and accumulated deltas through actual X11 events. + let + executable = findExe("Xvfb") + previousDisplay = getEnv("DISPLAY") + doAssert executable.len > 0, "Xvfb is required; this test never uses the desktop display." + let server = startProcess(executable, args = ["-displayfd", "1", "-screen", "0", + "320x240x24", "-nolisten", "tcp"], options = {}) + defer: + if server.running: server.terminate() + discard server.waitForExit(5000) + server.close() + defer: putEnv("DISPLAY", previousDisplay) + let number = server.outputStream.readLine().strip() + doAssert number.len > 0 and number.allCharsInSet({'0' .. '9'}) + let name = ":" & number + putEnv("DISPLAY", name) + let window = newWindow(WindowTitle, ivec2(64, 64), vsync = false) + defer: window.close() + let display = XOpenDisplay(name.cstring) + doAssert display != nil + defer: discard closeDisplay(display) + let handle = findWindow(display) + var calls = 0 + window.onScroll = proc() = + ## Count only public scroll callbacks generated by the event dispatcher. + inc calls + pollEvents() + for (button, expected) in [(WheelUp, vec2(0, -WheelDelta)), + (WheelDown, vec2(0, WheelDelta)), (WheelLeft, vec2(-WheelDelta, 0)), + (WheelRight, vec2(WheelDelta, 0))]: + calls = 0 + display.wheel(handle, button, true) + discard sync(display, 0) + pollEvents() + doAssert calls == 1, "Each wheel press must emit one callback." + doAssert window.scrollDelta == expected + display.wheel(handle, button, false) + discard sync(display, 0) + pollEvents() + doAssert calls == 1, "Wheel release must not emit another callback." + doAssert window.scrollDelta == vec2(0, 0) + calls = 0 + for gesture in 0 ..< 2: + display.wheel(handle, WheelUp, true) + display.wheel(handle, WheelUp, false) + discard sync(display, 0) + pollEvents() + doAssert calls == 2, "Two wheel gestures must emit exactly two callbacks." + doAssert window.scrollDelta == vec2(0, -2 * WheelDelta), + "Multiple presses in one poll must accumulate their scroll deltas." + pollEvents() + doAssert window.scrollDelta == vec2(0, 0) + echo "Windy X11 wheel: four directions, press-only callbacks, release suppression, batched accumulation, and per-frame reset passed." + + main() +else: + echo "Windy X11 wheel regression is Linux-only." From 1db2ef8d64afd0f5aecec8085d68f97fc3eb0eed Mon Sep 17 00:00:00 2001 From: monofuel Date: Fri, 30 Oct 2026 01:23:51 -0400 Subject: [PATCH 15/21] Label Linux cpuTime measurements as main-thread time --- docs/drm-gpu-proof.md | 12 +++++++----- tests/manual_drm_gpu.nim | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/drm-gpu-proof.md b/docs/drm-gpu-proof.md index 5e5242c3..b73a2322 100644 --- a/docs/drm-gpu-proof.md +++ b/docs/drm-gpu-proof.md @@ -58,11 +58,13 @@ conversion is scalar in this diagnostic; the player's XRGB converter uses SIMD. Unchanged-panel caching is intentionally absent because the pattern moves. Reports include generation, upload, rendering/finish, submission and completion -waits, mean/p95/max, process CPU time per frame, frames over 20 ms and paired +waits, mean/p95/max, main-thread CPU time per frame, frames over 20 ms and paired completion skew. GPU rendering also separates draw calls, EGL swap, GL finish and GBM acquisition. CPU submission includes full-panel uploads. GPU rendering includes EGL swap/GBM acquisition. Never compare the two labels as isolated -equivalent operations; compare total process CPU and the complete pipeline. +equivalent operations; compare main-thread CPU and the complete pipeline. +Nim's `cpuTime` uses the calling thread on Linux; these counters exclude Mesa +background threads and are not whole-process CPU measurements. Normal exit restores the captured plane/connector/CRTC state before releasing buffers. Cleanup errors are reported while remaining resources are still @@ -91,13 +93,13 @@ screen order, smooth markers and no visible moving tears. Matched eight-second runs with the same shipped ARM64 diagnostic: -| Source per panel / input | CPU reference CPU ms/frame | GPU CPU ms/frame | CPU / GPU frames | CPU / GPU frames over 20 ms | +| Source per panel / input | CPU reference main-thread ms/frame | GPU main-thread ms/frame | CPU / GPU frames | CPU / GPU frames over 20 ms | | --- | ---: | ---: | ---: | ---: | | 256×192 / XBGR, 4× full panel | 7.605 | 1.935 | 481 / 480 | 1 / 2 | | 256×224 / XBGR, 3× with borders | 7.603 | 2.118 | 481 / 481 | 2 / 2 | -All runs are approximately 60 FPS. CPU time includes synthetic frame generation; -GPU presentation reduces that total by roughly 72–75%. These are presentation +All runs are approximately 60 FPS. Main-thread time includes synthetic frame generation; +GPU presentation reduces that time by roughly 72–75%. These are presentation tests, not emulator benchmarks or measurements of player integration. Initial activation is included; no warmup frames are discarded. diff --git a/tests/manual_drm_gpu.nim b/tests/manual_drm_gpu.nim index 4ba3d4b2..9a36fb06 100644 --- a/tests/manual_drm_gpu.nim +++ b/tests/manual_drm_gpu.nim @@ -649,7 +649,7 @@ proc main() = if elapsed(frameStarted) > 20: inc late echo "Mode: ", paramStr(6), "; layout: ", paramStr(4), "; format: ", paramStr(5), "; frames: ", frames, "; elapsed ms: ", elapsed(started), "; frames over 20 ms: ", late - echo "Process CPU ms/frame: ", (cpuTime() - cpuStarted) * 1000 / frames.float64 + echo "Main-thread CPU ms/frame: ", (cpuTime() - cpuStarted) * 1000 / frames.float64 report("Generate", timings.generate) report("Upload", timings.upload) report("Render/finish", timings.render) From 24d59a57fc2ebdf1f417290dd8c15f7028315312 Mon Sep 17 00:00:00 2001 From: monofuel Date: Fri, 30 Oct 2026 01:37:11 -0400 Subject: [PATCH 16/21] Add explicit paired DRM GPU pixel presentation --- docs/drm-pixel-pairs.md | 53 ++ src/windy/drmpixels.nim | 32 + src/windy/platforms/linux/direct.nim | 2 + src/windy/platforms/linux/native/drm.nim | 11 +- src/windy/platforms/linux/native/drmgpu.nim | 669 ++++++++++++++++++ .../platforms/linux/native/pixelpair.nim | 55 ++ tests/config.nims | 5 +- tests/manual_drm_pixelpair.nim | 64 ++ tests/test_drm_pixelpair.nim | 41 ++ 9 files changed, 930 insertions(+), 2 deletions(-) create mode 100644 docs/drm-pixel-pairs.md create mode 100644 src/windy/drmpixels.nim create mode 100644 src/windy/platforms/linux/native/drmgpu.nim create mode 100644 src/windy/platforms/linux/native/pixelpair.nim create mode 100644 tests/manual_drm_pixelpair.nim create mode 100644 tests/test_drm_pixelpair.nim diff --git a/docs/drm-pixel-pairs.md b/docs/drm-pixel-pairs.md new file mode 100644 index 00000000..26ba371d --- /dev/null +++ b/docs/drm-pixel-pairs.md @@ -0,0 +1,53 @@ +# GPU pixel pairs + +`windy/drmpixels` adds GPU-scaled pixel presentation for two native DRM windows. +The caller supplies their order and source crops; device identities and game +layouts remain application concerns. Existing X11/fbdev/CPU rendering is retained. + +Enable Windy's DRM backend in the project's Nim configuration. Create two equal, +unrotated native windows on one DRM device with independent controllers, then +claim their pair before presenting CPU pixels to either window: + +```nim +import + pixie, windy, + windy/drmpixels + +proc showPanels(first, second: Window, frame: Image) = + ## Present two stacked panels without splitting their pixel storage. + let + pair = newDrmPixelPair(first, second) + panelHeight = frame.height div 2 + defer: pair.close() + pair.presentPixels(frame, [ + PixelRegion(width: frame.width, height: panelHeight), + PixelRegion(y: panelHeight, width: frame.width, height: panelHeight)]) +``` + +Keep the pair alive across frames. Source coordinates use the top-left origin; +a default/zero region leaves an output black. Scaling uses the largest positive +integer that fits, centered with black borders. Below native size it stays at1× +and clips to its output. Odd borders match CPU top/left centering. + +The packed Pixie RGBA image is read only during the call. Crops share one texture +upload and one linear combined GBM framebuffer. There is no CPU enlargement, +screen splitting or readback in normal presentation. EGL has no depth/stencil +or MSAA. GBM/EGL/GLES libraries load only when creating a pair; software renderers +are rejected. Unsupported paired scanout does not choose another backend. + +Both primary-plane framebuffer IDs change in one atomic transaction. Rendering +finishes before submission; old storage remains locked until both CRTC events +arrive. This coordinates generations, not physical panel refresh phases. +Presentation is synchronous and vblank paced; fencing/overlap is separate work. + +Use the creating thread. The pair activates its EGL context for rendering and +cleanup. Keep windows alive for input and close the pair before either window. +Claiming releases their unused CPU scanout buffers; reopen windows to return to +CPU presentation. Closed pairs reject presentation and tolerate repeated close. +Initialization and cleanup errors propagate. + +`tests/test_drm_pixelpair.nim` covers geometry, crops, event order and ownership. +`tests/manual_drm_pixelpair.nim CARD TOP BOTTOM` reads every GPU pixel and tests +real flips, odd borders, resizing, clipping, black outputs and swaps. Physical +tests need exclusive display ownership and a bounded service-restoring runner. +Initial measurements are in [the proof guide](drm-gpu-proof.md). diff --git a/src/windy/drmpixels.nim b/src/windy/drmpixels.nim new file mode 100644 index 00000000..950f4b18 --- /dev/null +++ b/src/windy/drmpixels.nim @@ -0,0 +1,32 @@ +import + pixie, + ./common, + ./platforms/linux/[backends, direct], + ./platforms/linux/native/[drm, pixelpair] + +export PixelRegion + +type DrmPixelPair* = ref object + display: GpuDisplay + +proc newDrmPixelPair*(first, second: Window): DrmPixelPair = + ## Claim two equal native DRM windows for GPU pixels; close them after this pair. + if first == nil or second == nil or first.direct == nil or second.direct == nil: + raise newException(WindyError, "GPU pixel pairs require native DRM windows") + let + a = first.direct + b = second.direct + if a.closed or b.closed or a.backend != DrmBackend or b.backend != DrmBackend or + a.drm == nil or b.drm == nil or a.screen == nil or b.screen == nil or a.drm != b.drm: + raise newException(WindyError, "GPU pixel pairs require open windows on one DRM device") + DrmPixelPair(display: newGpuDisplay(a.drm, [a.screen, b.screen])) + +proc presentPixels*(pair: DrmPixelPair, image: Image, regions: array[2, PixelRegion]) = + ## Center integer-scaled source crops, using black for an empty crop or nil image. + if pair == nil: raise newException(WindyError, "GPU pixel pair is nil") + pair.display.presentGpuPixels(image, regions) + +proc close*(pair: DrmPixelPair) = + ## Restore both outputs and release GPU resources before closing the windows. + if pair == nil: return + pair.display.close() diff --git a/src/windy/platforms/linux/direct.nim b/src/windy/platforms/linux/direct.nim index 4b099475..703decde 100644 --- a/src/windy/platforms/linux/direct.nim +++ b/src/windy/platforms/linux/direct.nim @@ -23,6 +23,8 @@ proc close*(window: Window) = if window.direct == nil or window.direct.closed: return let data = window.direct + if data.drm != nil and data.drm.gpuOwned: + raise newException(WindyError, "Close the GPU pixel presenter before its windows") data.closed = true try: if data.touch != nil: diff --git a/src/windy/platforms/linux/native/drm.nim b/src/windy/platforms/linux/native/drm.nim index 9a0e13a8..e2d441a5 100644 --- a/src/windy/platforms/linux/native/drm.nim +++ b/src/windy/platforms/linux/native/drm.nim @@ -1,7 +1,7 @@ import std/[dynlib, monotimes, os, posix, strformat, strutils, times], pixie, - ./[framebuffer, pixels] + ./[framebuffer, pixels, pixelpair] type ModeInfo = object @@ -128,6 +128,7 @@ type api: DrmApi fd: cint master: bool + gpuOwned*: bool screens*: seq[DrmScreen] retiredScreens: seq[DrmScreen] @@ -256,6 +257,8 @@ proc waitForFlip(display: DrmDisplay, screen: DrmScreen) = raiseOSError(osLastError(), "Reading DRM page flip events") proc closeScreen*(display: DrmDisplay, screen: DrmScreen) = + if display.gpuOwned: + raise newException(IOError, "Close the GPU pixel presenter before its windows") var errors: seq[string] try: display.waitForFlip(screen) @@ -310,6 +313,8 @@ proc closeScreen*(display: DrmDisplay, screen: DrmScreen) = raise newException(IOError, errors.join("; ")) proc close*(display: DrmDisplay) = + if display.gpuOwned: + raise newException(IOError, "Close the GPU pixel presenter before its display") var errors: seq[string] for screen in display.screens: try: @@ -505,6 +510,8 @@ proc upload*(display: DrmDisplay, index: int, image: Image) = addr screen.pixels[row * int(scanout.buffer.width)], int(scanout.buffer.width) * 4) proc present*(display: DrmDisplay, index: int, image: Image) = + if display.gpuOwned or display.screens[index].buffers.len == 0: + raise newException(IOError, "Reopen windows before switching from GPU to CPU pixels") display.upload(index, image) let screen = display.screens[index] if not screen.activated: @@ -533,3 +540,5 @@ proc openDrm*(path: string): DrmDisplay = let error = osLastError() result.close() raiseOSError(error, path) + +include drmgpu diff --git a/src/windy/platforms/linux/native/drmgpu.nim b/src/windy/platforms/linux/native/drmgpu.nim new file mode 100644 index 00000000..09e9d047 --- /dev/null +++ b/src/windy/platforms/linux/native/drmgpu.nim @@ -0,0 +1,669 @@ +# Included by the DRM backend to share its private native bindings. +type + GpuGbmHandle {.union.} = object + pointerValue: pointer + u32: uint32 + u64: uint64 + GpuAtomicRequest = pointer + GpuAtomicApi = object + alloc: proc(): GpuAtomicRequest {.cdecl.} + free: proc(request: GpuAtomicRequest) {.cdecl.} + add: proc(request: GpuAtomicRequest, id, gpuProperty: uint32, value: uint64): cint {.cdecl.} + gpuCommit: proc(fd: cint, request: GpuAtomicRequest, flags: uint32, data: pointer): cint {.cdecl.} + createBlob: proc(fd: cint, data: pointer, size: csize_t, id: ptr uint32): cint {.cdecl.} + destroyBlob: proc(fd: cint, id: uint32): cint {.cdecl.} + GpuAtomicValue = object + id, gpuProperty: uint32 + value: uint64 + GpuPairEvents = object + version: cint + vblank, flip: FlipHandler + flip2: proc(fd: cint, sequence, seconds, microseconds, crtc: cuint, + data: pointer) {.cdecl.} + GpuApi = ref object + gbmLibrary, eglLibrary, glesLibrary: LibHandle + gbm_create_device: proc(fd: cint): pointer {.cdecl.} + gbm_device_destroy: proc(device: pointer) {.cdecl.} + gbm_surface_create_with_modifiers2: proc(device: pointer, width, height, format: uint32, + modifiers: ptr uint64, count, flags: uint32): pointer {.cdecl.} + gbm_surface_destroy: proc(surface: pointer) {.cdecl.} + gbm_surface_lock_front_buffer: proc(surface: pointer): pointer {.cdecl.} + gbm_surface_release_buffer: proc(surface, bo: pointer) {.cdecl.} + gbm_bo_get_handle: proc(bo: pointer): GpuGbmHandle {.cdecl.} + gbm_bo_get_stride: proc(bo: pointer): uint32 {.cdecl.} + gbm_bo_get_modifier: proc(bo: pointer): uint64 {.cdecl.} + eglGetPlatformDisplay: proc(platform: cuint, native: pointer, attributes: pointer): pointer {.cdecl.} + eglInitialize: proc(display: pointer, major, minor: ptr cint): cuint {.cdecl.} + eglBindAPI: proc(api: cuint): cuint {.cdecl.} + eglChooseConfig: proc(display: pointer, attributes: ptr cint, configs: ptr pointer, + count: cint, found: ptr cint): cuint {.cdecl.} + eglGetConfigAttrib: proc(display, config: pointer, attribute: cint, value: ptr cint): cuint {.cdecl.} + eglCreateContext: proc(display, config, share: pointer, attributes: ptr cint): pointer {.cdecl.} + eglCreateWindowSurface: proc(display, config, window: pointer, attributes: pointer): pointer {.cdecl.} + eglMakeCurrent: proc(display, gpuDraw, read, context: pointer): cuint {.cdecl.} + eglSwapInterval: proc(display: pointer, interval: cint): cuint {.cdecl.} + eglSwapBuffers: proc(display, surface: pointer): cuint {.cdecl.} + eglDestroyContext: proc(display, context: pointer): cuint {.cdecl.} + eglDestroySurface: proc(display, surface: pointer): cuint {.cdecl.} + eglTerminate: proc(display: pointer): cuint {.cdecl.} + eglGetError: proc(): cuint {.cdecl.} + glGetString: proc(name: cuint): cstring {.cdecl.} + glCreateShader: proc(kind: cuint): cuint {.cdecl.} + glShaderSource: proc(gpuShader: cuint, count: cint, source: ptr cstring, lengths: pointer) {.cdecl.} + glCompileShader: proc(gpuShader: cuint) {.cdecl.} + glGetShaderiv: proc(gpuShader, name: cuint, value: ptr cint) {.cdecl.} + glGetShaderInfoLog: proc(gpuShader: cuint, size: cint, length: ptr cint, log: cstring) {.cdecl.} + glDeleteShader: proc(gpuShader: cuint) {.cdecl.} + glCreateProgram: proc(): cuint {.cdecl.} + glAttachShader: proc(program, gpuShader: cuint) {.cdecl.} + glBindAttribLocation: proc(program, index: cuint, name: cstring) {.cdecl.} + glLinkProgram: proc(program: cuint) {.cdecl.} + glGetProgramiv: proc(program, name: cuint, value: ptr cint) {.cdecl.} + glGetProgramInfoLog: proc(program: cuint, size: cint, length: ptr cint, log: cstring) {.cdecl.} + glDeleteProgram: proc(program: cuint) {.cdecl.} + glUseProgram: proc(program: cuint) {.cdecl.} + glGetUniformLocation: proc(program: cuint, name: cstring): cint {.cdecl.} + glUniform1i: proc(location, value: cint) {.cdecl.} + glGenTextures: proc(count: cint, textures: ptr cuint) {.cdecl.} + glDeleteTextures: proc(count: cint, textures: ptr cuint) {.cdecl.} + glGenBuffers: proc(count: cint, buffers: ptr cuint) {.cdecl.} + glBindBuffer: proc(target, buffer: cuint) {.cdecl.} + glBufferData: proc(target: cuint, size: int, data: pointer, usage: cuint) {.cdecl.} + glDeleteBuffers: proc(count: cint, buffers: ptr cuint) {.cdecl.} + glBindTexture: proc(target, texture: cuint) {.cdecl.} + glTexParameteri: proc(target, name: cuint, value: cint) {.cdecl.} + glTexImage2D: proc(target: cuint, level, internal, width, height, border: cint, + format, kind: cuint, data: pointer) {.cdecl.} + glTexSubImage2D: proc(target: cuint, level, x, y, width, height: cint, + format, kind: cuint, data: pointer) {.cdecl.} + glViewport: proc(x, y, width, height: cint) {.cdecl.} + glScissor: proc(x, y, width, height: cint) {.cdecl.} + glEnable: proc(capability: cuint) {.cdecl.} + glDisable: proc(capability: cuint) {.cdecl.} + glGetIntegerv: proc(name: cuint, value: ptr cint) {.cdecl.} + glClearColor: proc(red, green, blue, alpha: cfloat) {.cdecl.} + glClear: proc(mask: cuint) {.cdecl.} + glEnableVertexAttribArray: proc(index: cuint) {.cdecl.} + glVertexAttribPointer: proc(index: cuint, size: cint, kind: cuint, normalized: uint8, + stride: cint, data: pointer) {.cdecl.} + glDrawArrays: proc(mode: cuint, first, count: cint) {.cdecl.} + glFinish: proc() {.cdecl.} + glReadPixels: proc(x, y, width, height: cint, format, kind: cuint, data: pointer) {.cdecl.} + glGetError: proc(): cuint {.cdecl.} + GpuDisplay* = ref object + gpu: GpuApi + display: DrmDisplay + atomic: GpuAtomicApi + restore, properties: seq[GpuAtomicValue] + blobs: seq[uint32] + planes: array[2, uint32] + fbProperties: array[2, uint32] + pair: GpuFlipPair + armed, pending: bool + callbackError: string + gbm, surface, egl, context, eglSurface: pointer + program, texture, vertices: uint32 + gbmFrames: seq[tuple[bo: pointer, fb: uint32]] + locked: seq[pointer] + front: pointer + outputs: array[2, DrmScreen] + regions: array[2, PixelRegion] + panelWidth, panelHeight, textureWidth, textureHeight: int + maximumTextureSize: cint + +const + GpuPlaneObject = 0xeeeeeeee'u32 + GpuCrtcObject = 0xcccccccc'u32 + GpuConnectorObject = 0xc0c0c0c0'u32 + GpuAtomicTest = 0x100'u32 + GpuAtomicNonblock = 0x200'u32 + GpuAllowModeset = 0x400'u32 + GpuXrgb8888 = 0x34325258'u32 + GpuGlTexture = 0x0de1'u32 + GpuGlRgba = 0x1908'u32 + GpuGlByte = 0x1401'u32 + GpuGlFloat = 0x1406'u32 + GpuGlTriangles = 4'u32 + GpuGlColourBuffer = 0x4000'u32 + GpuGlScissor = 0x0c11'u32 + GpuGlMaxTextureSize = 0x0d33'u32 + GpuGlRenderer = 0x1f01'u32 + GpuGlVersion = 0x1f02'u32 + GpuGlVertexShader = 0x8b31'u32 + GpuGlFragmentShader = 0x8b30'u32 + GpuGlCompileStatus = 0x8b81'u32 + GpuGlLinkStatus = 0x8b82'u32 + GpuGlMagFilter = 0x2800'u32 + GpuGlMinFilter = 0x2801'u32 + GpuGlWrapS = 0x2802'u32 + GpuGlWrapT = 0x2803'u32 + GpuGlNearest = 0x2600.cint + GpuGlClampEdge = 0x812f.cint + GpuEglGbm = 0x31d7'u32 + GpuEglEsApi = 0x30a0'u32 + GpuEglNativeVisual = 0x302e.cint + GpuEglClientVersion = 0x3098.cint + GpuEglDepthSize = 0x3025.cint + GpuEglStencilSize = 0x3026.cint + GpuEglSamples = 0x3031.cint + GpuEglSampleBuffers = 0x3032.cint + GpuAtomicCapability = 3'u64 + GpuPrimaryPlane = 1'u64 + GpuGbmScanoutRendering = 5'u32 + GpuGlArrayBuffer = 0x8892'u32 + GpuGlStaticDraw = 0x88e4'u32 + GpuVerticesPerPanel = 6 + GpuVertexStride = 4 * sizeof(cfloat) + GpuEglNone = 0x3038.cint + GpuEglConfigAttributes = [0x3033.cint, 4, 0x3040, 4, 0x3024, 8, + 0x3023, 8, 0x3022, 8, 0x3025, 0, 0x3026, 0, GpuEglNone] + GpuVertexSource = "attribute vec2 position; attribute vec2 uv; varying vec2 tex; void main(){tex=uv;gl_Position=vec4(position,0.0,1.0);}" + GpuFragmentSource = "precision highp float; varying vec2 tex; uniform sampler2D image; void main(){gl_FragColor=vec4(texture2D(image,tex).rgb,1.0);}" + +static: + doAssert cpuEndian == littleEndian + doAssert sizeof(GpuGbmHandle) == 8 + doAssert sizeof(GpuPairEvents) == 32 + +proc close(api: GpuApi) = + ## Release only libraries opened for this GPU session. + if api == nil: return + for library in [api.glesLibrary, api.eglLibrary, api.gbmLibrary]: + if library != nil: unloadLib(library) + +proc gpuLoadApi(): GpuApi = + ## Resolve GPU entry points lazily so CPU-only applications need no Mesa. + result = GpuApi() + try: + result.gbmLibrary = loadLib("libgbm.so.1") + result.eglLibrary = loadLib("libEGL.so.1") + result.glesLibrary = loadLib("libGLESv2.so.2") + if result.gbmLibrary == nil or result.eglLibrary == nil or result.glesLibrary == nil: + raise newException(IOError, "GPU pixel presentation requires GBM, EGL and GLESv2") + template bindSymbol(field, library: untyped, name: string) = + result.field = cast[type(result.field)](symAddr(result.library, name)) + if result.field == nil: raise newException(IOError, "Missing GPU entry point: " & name) + bindSymbol(gbm_create_device, gbmLibrary, "gbm_create_device") + bindSymbol(gbm_device_destroy, gbmLibrary, "gbm_device_destroy") + bindSymbol(gbm_surface_create_with_modifiers2, gbmLibrary, "gbm_surface_create_with_modifiers2") + bindSymbol(gbm_surface_destroy, gbmLibrary, "gbm_surface_destroy") + bindSymbol(gbm_surface_lock_front_buffer, gbmLibrary, "gbm_surface_lock_front_buffer") + bindSymbol(gbm_surface_release_buffer, gbmLibrary, "gbm_surface_release_buffer") + bindSymbol(gbm_bo_get_handle, gbmLibrary, "gbm_bo_get_handle") + bindSymbol(gbm_bo_get_stride, gbmLibrary, "gbm_bo_get_stride") + bindSymbol(gbm_bo_get_modifier, gbmLibrary, "gbm_bo_get_modifier") + bindSymbol(eglGetPlatformDisplay, eglLibrary, "eglGetPlatformDisplay") + bindSymbol(eglInitialize, eglLibrary, "eglInitialize") + bindSymbol(eglBindAPI, eglLibrary, "eglBindAPI") + bindSymbol(eglChooseConfig, eglLibrary, "eglChooseConfig") + bindSymbol(eglGetConfigAttrib, eglLibrary, "eglGetConfigAttrib") + bindSymbol(eglCreateContext, eglLibrary, "eglCreateContext") + bindSymbol(eglCreateWindowSurface, eglLibrary, "eglCreateWindowSurface") + bindSymbol(eglMakeCurrent, eglLibrary, "eglMakeCurrent") + bindSymbol(eglSwapInterval, eglLibrary, "eglSwapInterval") + bindSymbol(eglSwapBuffers, eglLibrary, "eglSwapBuffers") + bindSymbol(eglDestroyContext, eglLibrary, "eglDestroyContext") + bindSymbol(eglDestroySurface, eglLibrary, "eglDestroySurface") + bindSymbol(eglTerminate, eglLibrary, "eglTerminate") + bindSymbol(eglGetError, eglLibrary, "eglGetError") + bindSymbol(glGetString, glesLibrary, "glGetString") + bindSymbol(glCreateShader, glesLibrary, "glCreateShader") + bindSymbol(glShaderSource, glesLibrary, "glShaderSource") + bindSymbol(glCompileShader, glesLibrary, "glCompileShader") + bindSymbol(glGetShaderiv, glesLibrary, "glGetShaderiv") + bindSymbol(glGetShaderInfoLog, glesLibrary, "glGetShaderInfoLog") + bindSymbol(glDeleteShader, glesLibrary, "glDeleteShader") + bindSymbol(glCreateProgram, glesLibrary, "glCreateProgram") + bindSymbol(glAttachShader, glesLibrary, "glAttachShader") + bindSymbol(glBindAttribLocation, glesLibrary, "glBindAttribLocation") + bindSymbol(glLinkProgram, glesLibrary, "glLinkProgram") + bindSymbol(glGetProgramiv, glesLibrary, "glGetProgramiv") + bindSymbol(glGetProgramInfoLog, glesLibrary, "glGetProgramInfoLog") + bindSymbol(glDeleteProgram, glesLibrary, "glDeleteProgram") + bindSymbol(glUseProgram, glesLibrary, "glUseProgram") + bindSymbol(glGetUniformLocation, glesLibrary, "glGetUniformLocation") + bindSymbol(glUniform1i, glesLibrary, "glUniform1i") + bindSymbol(glGenTextures, glesLibrary, "glGenTextures") + bindSymbol(glDeleteTextures, glesLibrary, "glDeleteTextures") + bindSymbol(glGenBuffers, glesLibrary, "glGenBuffers") + bindSymbol(glBindBuffer, glesLibrary, "glBindBuffer") + bindSymbol(glBufferData, glesLibrary, "glBufferData") + bindSymbol(glDeleteBuffers, glesLibrary, "glDeleteBuffers") + bindSymbol(glBindTexture, glesLibrary, "glBindTexture") + bindSymbol(glTexParameteri, glesLibrary, "glTexParameteri") + bindSymbol(glTexImage2D, glesLibrary, "glTexImage2D") + bindSymbol(glTexSubImage2D, glesLibrary, "glTexSubImage2D") + bindSymbol(glViewport, glesLibrary, "glViewport") + bindSymbol(glScissor, glesLibrary, "glScissor") + bindSymbol(glEnable, glesLibrary, "glEnable") + bindSymbol(glDisable, glesLibrary, "glDisable") + bindSymbol(glGetIntegerv, glesLibrary, "glGetIntegerv") + bindSymbol(glClearColor, glesLibrary, "glClearColor") + bindSymbol(glClear, glesLibrary, "glClear") + bindSymbol(glEnableVertexAttribArray, glesLibrary, "glEnableVertexAttribArray") + bindSymbol(glVertexAttribPointer, glesLibrary, "glVertexAttribPointer") + bindSymbol(glDrawArrays, glesLibrary, "glDrawArrays") + bindSymbol(glFinish, glesLibrary, "glFinish") + bindSymbol(glReadPixels, glesLibrary, "glReadPixels") + bindSymbol(glGetError, glesLibrary, "glGetError") + except: + result.close() + raise + +proc gpuChecked(value: bool, operation: string) = + ## Fail with the operation and current system error instead of continuing. + if not value: raise newException(IOError, operation & ": " & $osLastError()) + +proc gpuCheckedEgl(probe: GpuDisplay, value: cuint, operation: string) = + ## Include EGL's own error code when context or buffer operations fail. + if value == 0: raise newException(IOError, operation & ": EGL " & $probe.gpu.eglGetError()) + +proc gpuLoadAtomic(display: DrmDisplay): GpuAtomicApi = + ## Load experimental entry points without changing the normal DRM backend. + template bindField(field: untyped, name: string) = + result.field = cast[type(result.field)](symAddr(display.api.library, name)) + gpuChecked(result.field != nil, name) + bindField(alloc, "drmModeAtomicAlloc") + bindField(free, "drmModeAtomicFree") + bindField(add, "drmModeAtomicAddProperty") + bindField(gpuCommit, "drmModeAtomicCommit") + bindField(createBlob, "drmModeCreatePropertyBlob") + bindField(destroyBlob, "drmModeDestroyPropertyBlob") + +proc gpuProperty(probe: GpuDisplay, id, kind: uint32, name: string): GpuAtomicValue = + ## Find a gpuProperty by name and retain its original value for restoration. + let + api = probe.display.api + values = api.getProperties(probe.display.fd, id, kind) + gpuChecked(values != nil, "Reading properties") + defer: api.freeProperties(values) + for index in 0 ..< int(values.count): + let item = api.getProperty(probe.display.fd, values.ids[index]) + gpuChecked(item != nil, "Reading property") + let matches = $cast[cstring](addr item.name[0]) == name + api.freeProperty(item) + if matches: + return GpuAtomicValue(id: id, gpuProperty: values.ids[index], value: values.values[index]) + raise newException(IOError, "Missing DRM property " & name & " on " & $id) + +proc gpuSet(probe: GpuDisplay, id, kind: uint32, name: string, value: uint64) = + ## Append an atomic update and the matching original state exactly once. + let previous = probe.gpuProperty(id, kind, name) + probe.restore.add previous + probe.properties.add GpuAtomicValue(id: id, gpuProperty: previous.gpuProperty, value: value) + +proc gpuBlob(probe: GpuDisplay, mode: var ModeInfo): uint32 = + ## Retain a mode gpuBlob until after the display has been restored. + gpuChecked(probe.atomic.createBlob(probe.display.fd, addr mode, csize_t(sizeof(mode)), addr result) == 0, + "Creating mode blob") + probe.blobs.add result + +proc gpuConfigure(probe: GpuDisplay) = + ## Discover primary planes and construct one transaction for two active outputs. + let + display = probe.display + api = display.api + planes = api.getPlaneResources(display.fd) + resources = api.getResources(display.fd) + gpuChecked(planes != nil and resources != nil, "Enumerating planes") + defer: + api.freePlaneResources(planes) + api.freeResources(resources) + for index, screen in probe.outputs: + var crtcIndex = -1 + for candidate in 0 ..< int(resources.countCrtcs): + if resources.crtcs[candidate] == screen.crtc: crtcIndex = candidate + gpuChecked(crtcIndex >= 0, "Finding CRTC index") + for candidate in 0 ..< int(planes.count): + let plane = api.getPlane(display.fd, planes.planes[candidate]) + gpuChecked(plane != nil, "Reading plane") + defer: api.freePlane(plane) + if (plane.possibleCrtcs and (1'u32 shl crtcIndex)) == 0: continue + if probe.gpuProperty(plane.id, GpuPlaneObject, "type").value != GpuPrimaryPlane: continue + if plane.crtc notin [0'u32, screen.crtc]: continue + if plane.id in probe.planes: continue + var compatible = false + for format in 0 ..< int(plane.countFormats): + if plane.formats[format] == GpuXrgb8888: compatible = true + if compatible: + probe.planes[index] = plane.id + break + gpuChecked(probe.planes[index] != 0, "Finding compatible primary plane") + let plane = probe.planes[index] + probe.pair.crtcs[index] = screen.crtc + probe.gpuSet(screen.connector, GpuConnectorObject, "CRTC_ID", screen.crtc) + probe.gpuSet(screen.crtc, GpuCrtcObject, "ACTIVE", 1) + probe.gpuSet(screen.crtc, GpuCrtcObject, "MODE_ID", probe.gpuBlob(screen.mode)) + # Copy an active original mode into our own gpuBlob instead of retaining its ID. + if screen.original.modeValid != 0: + probe.restore[^1].value = probe.gpuBlob(screen.original.mode) + probe.gpuSet(plane, GpuPlaneObject, "CRTC_ID", screen.crtc) + probe.gpuSet(plane, GpuPlaneObject, "FB_ID", 0) + probe.fbProperties[index] = probe.properties[^1].gpuProperty + probe.gpuSet(plane, GpuPlaneObject, "SRC_X", 0) + probe.gpuSet(plane, GpuPlaneObject, "SRC_Y", uint64(index * probe.panelHeight) shl 16) + probe.gpuSet(plane, GpuPlaneObject, "SRC_W", uint64(probe.panelWidth) shl 16) + probe.gpuSet(plane, GpuPlaneObject, "SRC_H", uint64(probe.panelHeight) shl 16) + probe.gpuSet(plane, GpuPlaneObject, "CRTC_X", 0) + probe.gpuSet(plane, GpuPlaneObject, "CRTC_Y", 0) + probe.gpuSet(plane, GpuPlaneObject, "CRTC_W", uint64(probe.panelWidth)) + probe.gpuSet(plane, GpuPlaneObject, "CRTC_H", uint64(probe.panelHeight)) + echo screen.name, ": CRTC ", screen.crtc, ", plane ", plane + +proc gpuCommit(probe: GpuDisplay, values: seq[GpuAtomicValue], flags: uint32, data: pointer = nil) = + ## Submit a complete transaction or leave the current state unchanged on error. + let request = probe.atomic.alloc() + gpuChecked(request != nil, "Allocating atomic request") + defer: probe.atomic.free(request) + for item in values: + gpuChecked(probe.atomic.add(request, item.id, item.gpuProperty, item.value) >= 0, "Adding atomic property") + gpuChecked(probe.atomic.gpuCommit(probe.display.fd, request, flags, data) == 0, "Committing paired display update") + +proc gpuFlip2(fd: cint, sequence, seconds, microseconds, crtc: cuint, data: pointer) {.cdecl.} = + ## Handle both events without allowing a Nim exception across a C callback. + let probe = cast[GpuDisplay](data) + try: + probe.pair.complete(crtc, uint64(seconds) * 1_000_000 + uint64(microseconds)) + except ValueError as error: + probe.callbackError = error.msg + +proc gpuWaitPair(probe: GpuDisplay) = + ## Retire a generation only after both display controllers have completed it. + if not probe.pending: return + let started = getMonoTime() + var context = GpuPairEvents(version: 3, flip2: gpuFlip2) + while not probe.pair.ready: + let remaining = FlipTimeoutMs - int((getMonoTime() - started).inMilliseconds) + gpuChecked(remaining > 0, "Timed out waiting for paired flip") + var descriptor = TPollfd(fd: probe.display.fd, events: POLLIN) + let polled = posix.poll(addr descriptor, 1, cint(remaining)) + if polled < 0 and osLastError() == OSErrorCode(EINTR): continue + gpuChecked(polled > 0 and (descriptor.revents and POLLIN) != 0, "Polling paired flip") + gpuChecked(probe.display.api.handleEvent(probe.display.fd, cast[ptr EventContext](addr context)) == 0, + "Reading paired flip") + if probe.callbackError.len > 0: raise newException(IOError, probe.callbackError) + probe.pending = false + +proc gpuShader(probe: GpuDisplay, kind: uint32, source: string): uint32 = + ## Compile a shader and report the driver's compiler log on failure. + result = probe.gpu.glCreateShader(kind) + var + text = source.cstring + compiled: cint + probe.gpu.glShaderSource(result, 1, addr text, nil) + probe.gpu.glCompileShader(result) + probe.gpu.glGetShaderiv(result, GpuGlCompileStatus, addr compiled) + if compiled == 0: + var log = newString(4096) + probe.gpu.glGetShaderInfoLog(result, cint(log.len), nil, log.cstring) + probe.gpu.glDeleteShader(result) + raise newException(IOError, "Compiling shader: " & log) + +proc gpuInitialize(probe: GpuDisplay) = + ## Create one EGL context and one linear GBM render target for both panels. + probe.gbm = probe.gpu.gbm_create_device(probe.display.fd) + gpuChecked(probe.gbm != nil, "Creating GBM device") + var linear = 0'u64 + probe.surface = probe.gpu.gbm_surface_create_with_modifiers2(probe.gbm, uint32(probe.panelWidth), + uint32(probe.panelHeight * 2), GpuXrgb8888, addr linear, 1, GpuGbmScanoutRendering) + gpuChecked(probe.surface != nil, "Creating linear combined GBM surface") + probe.egl = probe.gpu.eglGetPlatformDisplay(GpuEglGbm, probe.gbm, nil) + gpuChecked(probe.egl != nil, "Opening GBM EGL display") + probe.gpuCheckedEgl(probe.gpu.eglInitialize(probe.egl, nil, nil), "Initializing EGL") + probe.gpuCheckedEgl(probe.gpu.eglBindAPI(GpuEglEsApi), "Selecting OpenGL ES") + var + attributes = GpuEglConfigAttributes + count: cint + selected: pointer + probe.gpuCheckedEgl(probe.gpu.eglChooseConfig(probe.egl, addr attributes[0], nil, 0, addr count), "Counting EGL configs") + gpuChecked(count > 0, "Expected a window-compatible EGL config") + var + configs = newSeq[pointer](count) + sizes: array[4, cint] + probe.gpuCheckedEgl(probe.gpu.eglChooseConfig(probe.egl, addr attributes[0], addr configs[0], count, addr count), "Choosing EGL config") + for index in 0 ..< int(count): + var visual: cint + probe.gpuCheckedEgl(probe.gpu.eglGetConfigAttrib(probe.egl, configs[index], GpuEglNativeVisual, addr visual), "Reading EGL visual") + if uint32(visual) != GpuXrgb8888: continue + for field, name in [GpuEglDepthSize, GpuEglStencilSize, GpuEglSamples, GpuEglSampleBuffers]: + probe.gpuCheckedEgl(probe.gpu.eglGetConfigAttrib(probe.egl, configs[index], name, addr sizes[field]), "Reading EGL buffers") + # EGL's zero attributes specify minimum sizes, not the absence of MSAA/depth. + if sizes == [0.cint, 0, 0, 0]: + selected = configs[index] + break + gpuChecked(selected != nil, "Finding XRGB EGL config without depth, stencil or MSAA") + echo "EGL depth/stencil/samples/sample buffers: ", sizes + var contextAttributes = [GpuEglClientVersion, 2.cint, GpuEglNone] + probe.context = probe.gpu.eglCreateContext(probe.egl, selected, nil, addr contextAttributes[0]) + gpuChecked(probe.context != nil, "Creating GLES2 context") + probe.eglSurface = probe.gpu.eglCreateWindowSurface(probe.egl, selected, probe.surface, nil) + gpuChecked(probe.eglSurface != nil, "Creating EGL window surface") + probe.gpuCheckedEgl(probe.gpu.eglMakeCurrent(probe.egl, probe.eglSurface, probe.eglSurface, probe.context), "Making context current") + probe.gpuCheckedEgl(probe.gpu.eglSwapInterval(probe.egl, 0), "Disabling EGL's separate refresh wait") + let + renderer = $probe.gpu.glGetString(GpuGlRenderer) + version = $probe.gpu.glGetString(GpuGlVersion) + echo "GPU: ", renderer, "; GLES: ", version + gpuChecked(renderer.len > 0, "Expected a hardware renderer") + for software in ["llvmpipe", "softpipe", "swrast", "swiftshader"]: + gpuChecked(software notin renderer.toLowerAscii, "Software rendering is not a GPU proof: " & renderer) + let + vertex = probe.gpuShader(GpuGlVertexShader, GpuVertexSource) + fragment = probe.gpuShader(GpuGlFragmentShader, GpuFragmentSource) + defer: + probe.gpu.glDeleteShader(vertex) + probe.gpu.glDeleteShader(fragment) + probe.program = probe.gpu.glCreateProgram() + probe.gpu.glAttachShader(probe.program, vertex) + probe.gpu.glAttachShader(probe.program, fragment) + probe.gpu.glBindAttribLocation(probe.program, 0, "position") + probe.gpu.glBindAttribLocation(probe.program, 1, "uv") + probe.gpu.glLinkProgram(probe.program) + var linked: cint + probe.gpu.glGetProgramiv(probe.program, GpuGlLinkStatus, addr linked) + if linked == 0: + var log = newString(4096) + probe.gpu.glGetProgramInfoLog(probe.program, cint(log.len), nil, log.cstring) + raise newException(IOError, "Linking program: " & log) + probe.gpu.glUseProgram(probe.program) + probe.gpu.glUniform1i(probe.gpu.glGetUniformLocation(probe.program, "image"), 0) + probe.gpu.glGenTextures(1, addr probe.texture) + probe.gpu.glBindTexture(GpuGlTexture, probe.texture) + for name in [GpuGlMagFilter, GpuGlMinFilter]: probe.gpu.glTexParameteri(GpuGlTexture, name, GpuGlNearest) + for name in [GpuGlWrapS, GpuGlWrapT]: probe.gpu.glTexParameteri(GpuGlTexture, name, GpuGlClampEdge) + probe.gpu.glGetIntegerv(GpuGlMaxTextureSize, addr probe.maximumTextureSize) + gpuChecked(probe.maximumTextureSize > 0, "Reading GPU texture capacity") + probe.gpu.glEnableVertexAttribArray(0) + probe.gpu.glEnableVertexAttribArray(1) + probe.gpu.glGenBuffers(1, addr probe.vertices) + probe.gpu.glBindBuffer(GpuGlArrayBuffer, probe.vertices) + +proc gpuUpload(probe: GpuDisplay, image: Image, regions: array[2, PixelRegion]) = + ## Upload one native-size image and retain geometry until its crops change. + let + width = if image == nil: 0 else: image.width + height = if image == nil: 0 else: image.height + validateRegions(width, height, regions) + if image == nil: + probe.regions = regions + return + if width <= 0 or height <= 0 or width > int(probe.maximumTextureSize) or + height > int(probe.maximumTextureSize) or image.data.len != width * height: + raise newException(ValueError, "Invalid GPU source image dimensions") + let resized = width != probe.textureWidth or height != probe.textureHeight + if resized or regions != probe.regions: + var vertices = pixelVertices(width, height, regions) + probe.gpu.glBufferData(GpuGlArrayBuffer, sizeof(vertices), addr vertices[0], GpuGlStaticDraw) + if resized: + probe.gpu.glTexImage2D(GpuGlTexture, 0, cint(GpuGlRgba), cint(width), cint(height), + 0, GpuGlRgba, GpuGlByte, unsafeAddr image.data[0]) + else: + probe.gpu.glTexSubImage2D(GpuGlTexture, 0, 0, 0, cint(width), cint(height), + GpuGlRgba, GpuGlByte, unsafeAddr image.data[0]) + probe.textureWidth = width + probe.textureHeight = height + probe.regions = regions + gpuChecked(probe.gpu.glGetError() == 0, "Uploading GPU pixels") + +proc gpuDraw(probe: GpuDisplay) = + ## Enlarge two cropped texture regions with nearest-neighbour sampling. + probe.gpu.glDisable(GpuGlScissor) + probe.gpu.glClearColor(0, 0, 0, 1) + probe.gpu.glClear(GpuGlColourBuffer) + probe.gpu.glEnable(GpuGlScissor) + for index in 0..1: + if probe.regions[index] == PixelRegion(): continue + let + offset = index * GpuVerticesPerPanel * GpuVertexStride + view = pixelViewport(probe.regions[index], probe.panelWidth, probe.panelHeight) + probe.gpu.glScissor(0, cint((1 - index) * probe.panelHeight), + cint(probe.panelWidth), cint(probe.panelHeight)) + probe.gpu.glViewport(cint(view.x), cint((2 - index) * probe.panelHeight - view.y - view.height), + cint(view.width), cint(view.height)) + probe.gpu.glVertexAttribPointer(0, 2, GpuGlFloat, 0, GpuVertexStride, cast[pointer](offset)) + probe.gpu.glVertexAttribPointer(1, 2, GpuGlFloat, 0, GpuVertexStride, cast[pointer](offset + 2 * sizeof(cfloat))) + probe.gpu.glDrawArrays(GpuGlTriangles, 0, GpuVerticesPerPanel) + gpuChecked(probe.gpu.glGetError() == 0, "Drawing GPU pixels") + +proc gpuFramebuffer(probe: GpuDisplay, bo: pointer): uint32 = + ## Register each GBM allocation once and reject non-linear storage explicitly. + for frame in probe.gbmFrames: + if frame.bo == bo: return frame.fb + gpuChecked(probe.gpu.gbm_bo_get_modifier(bo) == 0, "Expected a linear GBM buffer") + var + handles = [probe.gpu.gbm_bo_get_handle(bo).u32, 0'u32, 0, 0] + pitches = [probe.gpu.gbm_bo_get_stride(bo), 0'u32, 0, 0] + offsets: array[4, uint32] + gpuChecked(probe.display.api.addFb2(probe.display.fd, uint32(probe.panelWidth), + uint32(probe.panelHeight * 2), GpuXrgb8888, addr handles[0], addr pitches[0], + addr offsets[0], addr result, 0) == 0, "Registering combined GBM framebuffer") + probe.gbmFrames.add (bo, result) + +proc gpuSubmit(probe: GpuDisplay, fb: uint32) = + ## Test the initial pair and then submit one vblank-synchronized transaction. + var values: seq[GpuAtomicValue] + for item in probe.properties: + if probe.armed and item.gpuProperty notin probe.fbProperties: continue + var value = item + if item.id in probe.planes: + for index in 0..1: + if item.id == probe.planes[index] and item.gpuProperty == probe.fbProperties[index]: + value.value = fb + values.add value + if not probe.armed: + probe.gpuCommit(values, GpuAtomicTest or GpuAllowModeset) + echo "Atomic TEST_ONLY: combined framebuffer accepted by both outputs" + probe.gpuCommit(values, GpuAllowModeset) + probe.armed = true + else: + probe.pair.completed = {} + probe.gpuCommit(values, GpuAtomicNonblock or FlipEvent, cast[pointer](probe)) + probe.pending = true + +proc close*(probe: GpuDisplay) = + ## Restore the saved transaction before freeing anything still used for scanout. + if probe == nil or probe.display == nil: return + var errors: seq[string] + template attempt(body: untyped) = + try: body + except CatchableError as error: errors.add error.msg + if probe.armed: + attempt: probe.gpuWaitPair() + attempt: + probe.gpuCommit(probe.restore, GpuAllowModeset) + probe.armed = false + if probe.armed: + # A failed restore must not leave scanout pointing at freed GPU storage. + var disabled = true + for screen in probe.display.screens: + if probe.display.api.setCrtc(probe.display.fd, screen.crtc, 0, 0, 0, nil, 0, nil) < 0: + disabled = false + errors.add "Disabling output after failed restore: " & screen.name + if not disabled: + attempt: gpuChecked(posix.close(probe.display.fd) == 0, "Closing failed DRM session") + probe.display.fd = -1 + for screen in probe.display.screens: + for scanout in screen.buffers: + attempt: gpuChecked(munmap(scanout.memory, int(scanout.buffer.size)) == 0, "Unmapping abandoned CPU buffer") + screen.buffers.setLen(0) + for bo in probe.locked: probe.gpu.gbm_surface_release_buffer(probe.surface, bo) + probe.locked.setLen(0) + for frame in probe.gbmFrames: + if probe.display.fd >= 0: + attempt: gpuChecked(probe.display.api.removeFb(probe.display.fd, frame.fb) == 0, "Removing GPU framebuffer") + if probe.egl != nil: + if probe.context != nil: + attempt: + probe.gpuCheckedEgl(probe.gpu.eglMakeCurrent(probe.egl, probe.eglSurface, + probe.eglSurface, probe.context), "Activating context for GPU cleanup") + if probe.texture != 0: probe.gpu.glDeleteTextures(1, addr probe.texture) + if probe.vertices != 0: probe.gpu.glDeleteBuffers(1, addr probe.vertices) + if probe.program != 0: probe.gpu.glDeleteProgram(probe.program) + attempt: probe.gpuCheckedEgl(probe.gpu.eglMakeCurrent(probe.egl, nil, nil, nil), "Releasing EGL context") + if probe.eglSurface != nil: + attempt: probe.gpuCheckedEgl(probe.gpu.eglDestroySurface(probe.egl, probe.eglSurface), "Destroying EGL surface") + if probe.context != nil: + attempt: probe.gpuCheckedEgl(probe.gpu.eglDestroyContext(probe.egl, probe.context), "Destroying EGL context") + attempt: probe.gpuCheckedEgl(probe.gpu.eglTerminate(probe.egl), "Terminating EGL") + if probe.surface != nil: probe.gpu.gbm_surface_destroy(probe.surface) + if probe.gbm != nil: probe.gpu.gbm_device_destroy(probe.gbm) + if probe.display.fd >= 0: + for gpuBlob in probe.blobs: + attempt: gpuChecked(probe.atomic.destroyBlob(probe.display.fd, gpuBlob) == 0, "Destroying mode blob") + probe.display.gpuOwned = false + probe.display = nil + probe.gpu.close() + probe.gpu = nil + if errors.len > 0: raise newException(IOError, errors.join("; ")) + +proc newGpuDisplay*(display: DrmDisplay, outputs: array[2, DrmScreen]): GpuDisplay = + ## Claim two idle unrotated outputs; close this presenter before its windows. + if display == nil or display.fd < 0 or display.gpuOwned or + display.screens.len != 2 or outputs[0] == outputs[1]: + raise newException(ValueError, "GPU pixels require two exclusively owned DRM outputs") + for screen in outputs: + if screen notin display.screens or screen.rotation != 0 or screen.activated or screen.pending: + raise newException(ValueError, "GPU pixels require idle, unrotated DRM windows") + if outputs[0].crtc == outputs[1].crtc: + raise newException(ValueError, "GPU pixels require independent display controllers") + if outputs[0].width != outputs[1].width or outputs[0].height != outputs[1].height: + raise newException(ValueError, "GPU pixels require equally sized outputs") + result = GpuDisplay(display: display, outputs: outputs, + panelWidth: outputs[0].width, panelHeight: outputs[0].height) + try: + result.gpu = gpuLoadApi() + result.atomic = gpuLoadAtomic(display) + gpuChecked(display.api.setClientCap(display.fd, GpuAtomicCapability, 1) == 0, "Enabling atomic KMS") + result.gpuConfigure() + for screen in outputs: + display.closeScreen(screen) + screen.buffers.setLen(0) + result.gpuInitialize() + gpuChecked(display.api.setMaster(display.fd) == 0, "Acquiring DRM master") + display.master = true + display.gpuOwned = true + except: + result.close() + raise + +proc presentGpuPixels*(probe: GpuDisplay, image: Image, regions: array[2, PixelRegion]) = + ## Upload once, scale on the GPU, and retire storage after both vblank events. + if probe == nil or probe.display == nil: + raise newException(ValueError, "GPU pixel presenter is closed") + probe.gpuCheckedEgl(probe.gpu.eglMakeCurrent(probe.egl, probe.eglSurface, + probe.eglSurface, probe.context), "Activating GPU pixel context") + probe.gpuUpload(image, regions) + probe.gpuDraw() + probe.gpuCheckedEgl(probe.gpu.eglSwapBuffers(probe.egl, probe.eglSurface), "Swapping EGL render buffer") + probe.gpu.glFinish() + gpuChecked(probe.gpu.glGetError() == 0, "Finishing GPU pixels") + let bo = probe.gpu.gbm_surface_lock_front_buffer(probe.surface) + gpuChecked(bo != nil, "Locking completed GBM buffer") + probe.locked.add bo + probe.gpuSubmit(probe.gpuFramebuffer(bo)) + probe.gpuWaitPair() + if probe.front != nil: + probe.gpu.gbm_surface_release_buffer(probe.surface, probe.front) + probe.locked.delete(probe.locked.find(probe.front)) + probe.front = bo + diff --git a/src/windy/platforms/linux/native/pixelpair.nim b/src/windy/platforms/linux/native/pixelpair.nim new file mode 100644 index 00000000..9b559a24 --- /dev/null +++ b/src/windy/platforms/linux/native/pixelpair.nim @@ -0,0 +1,55 @@ +type + PixelRegion* = object + x*, y*, width*, height*: int + GpuFlipPair* = object + crtcs*: array[2, uint32] + completed*: set[range[0..1]] + timestamps*: array[2, uint64] + +proc validateRegions*(width, height: int, regions: array[2, PixelRegion]) = + ## Validate source crops; an empty region leaves its output black. + for region in regions: + if region == PixelRegion(): continue + if width <= 0 or height <= 0 or region.x < 0 or region.y < 0 or + region.width <= 0 or region.height <= 0 or region.x > width or + region.y > height or region.width > width - region.x or + region.height > height - region.y: + raise newException(ValueError, "Invalid pixel source region") + +proc pixelViewport*(region: PixelRegion, width, height: int): PixelRegion = + ## Center whole-number scaling, clipping at native size when necessary. + if min(region.width, region.height) <= 0 or min(width, height) <= 0: + raise newException(ValueError, "Invalid pixel viewport dimensions") + let scale = max(1, min(width div region.width, height div region.height)) + result.width = region.width * scale + result.height = region.height * scale + result.x = (width - result.width) div 2 + result.y = (height - result.height) div 2 + +proc pixelVertices*(width, height: int, regions: array[2, PixelRegion]): array[48, cfloat] = + ## Address top-left source crops with OpenGL's bottom-left vertex convention. + validateRegions(width, height, regions) + if width <= 0 or height <= 0: return + for index, region in regions: + let + left = cfloat(region.x) / cfloat(width) + right = cfloat(region.x + region.width) / cfloat(width) + top = cfloat(region.y) / cfloat(height) + bottom = cfloat(region.y + region.height) / cfloat(height) + vertices = [-1.cfloat, -1, left, bottom, 1, -1, right, bottom, + -1, 1, left, top, -1, 1, left, top, 1, -1, right, bottom, 1, 1, right, top] + for offset, value in vertices: result[index * vertices.len + offset] = value + +proc complete*(pair: var GpuFlipPair, crtc: uint32, timestamp: uint64) = + ## Record one event per controller before either old scanout can be retired. + for index in 0..1: + if pair.crtcs[index] == crtc: + if index in pair.completed: raise newException(ValueError, "Duplicate flip completion") + pair.completed.incl index + pair.timestamps[index] = timestamp + return + raise newException(ValueError, "Completion from an unexpected CRTC") + +proc ready*(pair: GpuFlipPair): bool = + ## Return whether both controllers have completed the generation. + pair.completed == {0, 1} diff --git a/tests/config.nims b/tests/config.nims index fa17b588..5c141732 100644 --- a/tests/config.nims +++ b/tests/config.nims @@ -4,11 +4,14 @@ when defined(emscripten): include "../examples/config.nims" import std/os -if projectName() == "manual_drm_gpu" and not defined(debug): +if projectName() in ["manual_drm_gpu", "manual_drm_pixelpair"] and not defined(debug): # Measure the same shipped settings as the graphical applications. switch("define", "release") switch("define", "danger") let testBackends = getEnv("WINDY_TEST_BACKENDS") +if projectName() == "test_drm_pixelpair" and testBackends.len == 0: + switch("define", "useCpu") + switch("define", "windyBackends=drm") if testBackends.len > 0: switch("define", "useCpu") switch("define", "windyBackends=" & testBackends) diff --git a/tests/manual_drm_pixelpair.nim b/tests/manual_drm_pixelpair.nim new file mode 100644 index 00000000..07599579 --- /dev/null +++ b/tests/manual_drm_pixelpair.nim @@ -0,0 +1,64 @@ +include windy/platforms/linux/native/drm + +proc pattern(width, height: int): Image = + ## Generate asymmetric pixel colours without loading game data. + result = newImage(width, height) + for y in 0 ..< height: + for x in 0 ..< width: + result.data[y * width + x] = rgbx(uint8(x mod 256), uint8(y mod 256), + uint8((x xor y) mod 256), 255) + +proc verify(pair: GpuDisplay, image: Image, regions: array[2, PixelRegion]) = + ## Read every GPU pixel against source crops, integer scaling and black borders. + pair.gpuUpload(image, regions) + pair.gpuDraw() + let + width = pair.panelWidth + height = pair.panelHeight + var pixels = newSeq[uint32](width * height * 2) + pair.gpu.glReadPixels(0, 0, cint(width), cint(height * 2), GpuGlRgba, GpuGlByte, addr pixels[0]) + gpuChecked(pair.gpu.glGetError() == 0, "Reading pixel pair") + for index, crop in regions: + let + scale = if crop.width == 0: 1 else: max(1, min(width div crop.width, height div crop.height)) + left = (width - crop.width * scale) div 2 + top = (height - crop.height * scale) div 2 + for y in 0 ..< height: + for x in 0 ..< width: + var expected = 0xff000000'u32 + if crop.width > 0 and x >= left and y >= top and + x < left + crop.width * scale and y < top + crop.height * scale: + let + sourceX = crop.x + (x - left) div scale + sourceY = crop.y + (y - top) div scale + expected = cast[uint32](image.data[sourceY * image.width + sourceX]) + let actual = pixels[(height * 2 - 1 - index * height - y) * width + x] or 0xff000000'u32 + if actual != expected: + raise newException(IOError, &"Pair pixel mismatch on output {index} at {x},{y}: {actual} != {expected}") + pair.presentGpuPixels(image, regions) + +proc main() = + ## Run bounded silent pixel/flip checks against the production GPU implementation. + if paramCount() != 3: + raise newException(ValueError, "Usage: manual_drm_pixelpair CARD TOP BOTTOM") + let display = openDrm(paramStr(1)) + defer: display.close() + display.openOutput(@[paramStr(2), paramStr(3)], 0, true) + let pair = newGpuDisplay(display, [display.screens[0], display.screens[1]]) + defer: pair.close() + for panelHeight in [192, 224]: + pair.verify(pattern(256, panelHeight * 2), [PixelRegion(width: 256, height: panelHeight), + PixelRegion(y: panelHeight, width: 256, height: panelHeight)]) + let single = pattern(240, 160) + pair.verify(single, [PixelRegion(width: 240, height: 160), PixelRegion()]) + pair.verify(single, [PixelRegion(), PixelRegion(width: 240, height: 160)]) + pair.verify(pattern(19, 26), [PixelRegion(width: 19, height: 13), + PixelRegion(y: 13, width: 19, height: 13)]) + pair.verify(pattern(pair.panelWidth + 16, pair.panelHeight + 8), + [PixelRegion(width: pair.panelWidth + 16, height: pair.panelHeight + 8), PixelRegion()]) + pair.verify(pattern(320, 240), [PixelRegion(), PixelRegion(width: 320, height: 240)]) + pair.verify(nil, [PixelRegion(), PixelRegion()]) + echo "GPU pixel pair: all pixels, crops, resize, blank/swap, clipping and paired flips passed" + +when isMainModule: + main() diff --git a/tests/test_drm_pixelpair.nim b/tests/test_drm_pixelpair.nim new file mode 100644 index 00000000..58fa8dbe --- /dev/null +++ b/tests/test_drm_pixelpair.nim @@ -0,0 +1,41 @@ +import + std/unittest, + windy/[common, drmpixels], + windy/platforms/linux/native/pixelpair + +suite "GPU pixel pairs": + test "DS and EarthBound preserve integer pixels and borders": + check pixelViewport(PixelRegion(width: 256, height: 192), 1024, 768) == + PixelRegion(width: 1024, height: 768) + check pixelViewport(PixelRegion(width: 256, height: 224), 1024, 768) == + PixelRegion(x: 128, y: 48, width: 768, height: 672) + check pixelViewport(PixelRegion(width: 100, height: 80), 60, 50) == + PixelRegion(x: -20, y: -15, width: 100, height: 80) + test "source crops and black outputs validate before rendering": + validateRegions(0, 0, [PixelRegion(), PixelRegion()]) + validateRegions(256, 448, [PixelRegion(width: 256, height: 224), + PixelRegion(y: 224, width: 256, height: 224)]) + for region in [PixelRegion(width: 0, height: 1), + PixelRegion(x: -1, width: 1, height: 1), + PixelRegion(x: high(int), width: 1, height: 1), + PixelRegion(width: 257, height: 1)]: + expect ValueError: validateRegions(256, 448, [region, PixelRegion()]) + test "texture crops retain top and bottom orientation": + let vertices = pixelVertices(256, 448, [PixelRegion(width: 256, height: 224), + PixelRegion(y: 224, width: 256, height: 224)]) + check vertices[3] == 0.5 + check vertices[11] == 0 + check vertices[27] == 1 + check vertices[35] == 0.5 + test "both controllers must complete in either order": + for reverse in [false, true]: + var pair = GpuFlipPair(crtcs: [69'u32, 70]) + pair.complete(if reverse: 70 else: 69, 100) + check not pair.ready + pair.complete(if reverse: 69 else: 70, 106) + check pair.ready + expect ValueError: pair.complete(69, 107) + expect ValueError: pair.complete(71, 107) + test "invalid window ownership fails without opening GPU libraries": + expect WindyError: discard newDrmPixelPair(nil, nil) + close(DrmPixelPair(nil)) From 488b6141ea6d1f8a43221bfba4866df8268819ed Mon Sep 17 00:00:00 2001 From: monofuel Date: Fri, 30 Oct 2026 23:24:32 -0400 Subject: [PATCH 17/21] Add opt-in X11 GPU pixel presentation with GLX vsync --- Makefile | 4 +- docs/x11-pixels.md | 44 ++++ src/windy/platforms/linux/backends.nim | 2 + src/windy/platforms/linux/platform.nim | 10 + src/windy/platforms/linux/x11.nim | 270 ++++++++++++++++--------- src/windy/platforms/linux/x11/glx.nim | 12 +- src/windy/x11pixels.nim | 173 ++++++++++++++++ tests/config.nims | 3 + tests/test_x11_pixels.nim | 131 ++++++++++++ 9 files changed, 547 insertions(+), 102 deletions(-) create mode 100644 docs/x11-pixels.md create mode 100644 src/windy/x11pixels.nim create mode 100644 tests/test_x11_pixels.nim diff --git a/Makefile b/Makefile index 643eddbe..da1818aa 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: test-native test-linux-cpu test-linux-combined +.PHONY: test-native test-linux-cpu test-linux-combined test-x11-pixels +test-x11-pixels: + nim r tests/test_x11_pixels.nim test-native: nim r tests/test_native_input.nim nim r tests/test_linux_framebuffer.nim diff --git a/docs/x11-pixels.md b/docs/x11-pixels.md new file mode 100644 index 00000000..fb2d0f01 --- /dev/null +++ b/docs/x11-pixels.md @@ -0,0 +1,44 @@ +# X11 GPU pixels + +`windy/x11pixels` presents opaque Pixie images through a double-buffered GLX +drawable, including in `-d:useCpu` applications. It leaves ordinary CPU windows +and native DRM/fbdev presentation unchanged. Opt in with +`LinuxWindowOptions(gpuPixels: true)` on `newWindow`. + +Create a `newX11PixelPresenter(window)`, call `drawPixels(image, region)` then +`swap()`, and close the presenter before its window. Create, draw, swap and close +on the window thread. Each presenter owns one texture, shader program and vertex +buffer. The context needs OpenGL 2.1 / GLSL 1.20. + +Images upload as RGBA bytes without channel conversion; the shader forces opaque +output. Native-size textures use nearest filtering, integer scaling and centered +black borders. An explicit `scale` must fit. `fitDown` permits fractional nearest +scaling for a modal UI in a small window; leave it false for game pixels. Regions +use top-left coordinates. A nil image with an empty region clears black. + +`changed = false` retains texture pixels; callers must still draw after resizes +or crop changes. A different source size reallocates storage even when `changed` +is false. For two windows, draw both back buffers first and then swap both. This +submits drawing before presentation waits; it does not promise atomic updates +across windows. + +`vsync = true` requests swap interval one through the advertised EXT, MESA or SGI +GLX extension. Startup fails if no supported extension can configure it. +`gpuPixelVsync` reports successful configuration, not measured physical refresh. +`vsync = false` is an explicit diagnostic/headless mode. `hasGpuPixelContext` +checks the opt-in drawable; CPU `presentPixels` is not its presentation API. +GPU initialization and driver errors remain visible to callers. + +## Verification + +Run `nim r tests/test_x11_pixels.nim` with Xvfb installed. It starts and stops its +own isolated server and uses software Mesa, never the user's desktop. Actual GL +readback is compared against an independent CPU pixel reference for colours, +opacity, crop orientation, borders, forced scale, UI downscale, duplicates, +source-size changes and independent contexts. Cleanup and paired submission are +also checked. `nim r tests/test_drm_pixelpair.nim` retains the headless native checks. + +Xvfb does not provide physical vsync. Real-display acceptance must check moving +imagery for tearing and normal pacing with both windows visible, resized, +minimized and restored. Applications should retain an explicit CPU comparison +path. Automated playback remains muted or uses a null audio backend. diff --git a/src/windy/platforms/linux/backends.nim b/src/windy/platforms/linux/backends.nim index 70f13de1..1869c29e 100644 --- a/src/windy/platforms/linux/backends.nim +++ b/src/windy/platforms/linux/backends.nim @@ -13,6 +13,8 @@ type LinuxWindowOptions* = object device*, output*, touchDevice*: string rotation*, touchRotation*: int + gpuPixels*: bool + ## Opt into a double-buffered X11 OpenGL drawable in CPU pixel builds. static: for backend in windyBackends.split(','): diff --git a/src/windy/platforms/linux/platform.nim b/src/windy/platforms/linux/platform.nim index 1a86728b..34de0a61 100644 --- a/src/windy/platforms/linux/platform.nim +++ b/src/windy/platforms/linux/platform.nim @@ -238,6 +238,16 @@ else: return direct.unsupported() + proc hasGpuPixelContext*(window: Window): bool = + ## Report an open opt-in X11 GPU drawable without selecting a native backend. + when HasX11: + if window != nil and window.direct == nil: return x11.hasGpuPixelContext(window) + + proc gpuPixelVsync*(window: Window): bool = + ## Report whether X11 swap control enabled vsync for this pixel window. + when HasX11: + if window != nil and window.direct == nil: return x11.gpuPixelVsync(window) + proc swapBuffers*(window: Window) = when HasX11: if window.direct == nil: diff --git a/src/windy/platforms/linux/x11.nim b/src/windy/platforms/linux/x11.nim index dd0a6233..096a4b51 100644 --- a/src/windy/platforms/linux/x11.nim +++ b/src/windy/platforms/linux/x11.nim @@ -44,6 +44,8 @@ type handle: XWindow ctx: GlxContext + gpuPixelOptIn: bool + gpuPixelVsyncState: bool gc: GC ic: XIC im: XIM @@ -389,6 +391,13 @@ proc queryKeyboardState(): set[0..255] = return cast[ptr set[0..255]](r.addr)[] proc destroy(window: Window) = + if window.closed: + return + if window.ctx != nil: + if glXGetCurrentContext() == window.ctx: + display.glXMakeCurrent(0.Drawable, nil) + display.glXDestroyContext(window.ctx) + window.ctx = nil if window.ic != nil: XDestroyIC(window.ic) if window.im != nil: @@ -407,6 +416,14 @@ proc closed*(window: Window): bool = window.closed proc close*(window: Window) = destroy window +proc hasGpuPixelContext*(window: Window): bool = + ## True when GPU pixels were opted in, a GLX context is present, and the window is open. + window != nil and window.gpuPixelOptIn and window.ctx != nil and not window.closed + +proc gpuPixelVsync*(window: Window): bool = + ## True only if opt-in GPU vsync was successfully configured. + window != nil and not window.closed and window.gpuPixelVsyncState + proc makeContextCurrent*(window: Window) = display.glXMakeCurrent(window.handle, window.ctx) @@ -732,6 +749,15 @@ proc `cursor=`*(window: Window, v: common.Cursor) = window.state.cursor = v window.applyCursor() +proc hasGlxExtension(name: string): bool = + ## True when the GLX server advertises this exact extension name. + let raw = display.glXQueryExtensionsString(display.defaultScreen) + if raw == nil: + return false + for part in split($raw): + if part == name: + return true + proc newWindow*( title: string, size: IVec2, @@ -749,112 +775,158 @@ proc newWindow*( result = Window() result.innerDecorated = true result.state.runeInputEnabled = true + result.gpuPixelOptIn = linuxOptions.gpuPixels + try: + let root = display.defaultRootWindow + + var vi: XVisualInfo + if linuxOptions.gpuPixels: + let vis = + if style == Transparent: + var attribList = [ + GlxRgba, GlxDoublebuffer, GlxAlphaSize, 8'i32, + GlxRedSize, 8'i32, GlxGreenSize, 8'i32, GlxBlueSize, 8'i32, + GlxDepthSize, 0'i32, GlxStencilSize, 0'i32, + GlxSampleBuffers, 0'i32, 0'i32 + ] + display.glXChooseVisual(display.defaultScreen, attribList[0].addr) + else: + var attribList = [ + GlxRgba, GlxDoublebuffer, + GlxRedSize, 8'i32, GlxGreenSize, 8'i32, GlxBlueSize, 8'i32, + GlxDepthSize, 0'i32, GlxStencilSize, 0'i32, + GlxSampleBuffers, 0'i32, 0'i32 + ] + display.glXChooseVisual(display.defaultScreen, attribList[0].addr) + if vis == nil: + if style == Transparent: + raise WindyError.newException("Transparent GPU pixel windows are not supported") + raise WindyError.newException("Error choosing OpenGL visual") + vi = vis[] + XFree(vis) + if style == Transparent and vi.depth < 32: + raise WindyError.newException("Transparent GPU pixel windows are not supported") + elif style == Transparent: + display.XMatchVisualInfo(display.defaultScreen, 32, TrueColor, vi.addr) + else: + display.XMatchVisualInfo(display.defaultScreen, 24, TrueColor, vi.addr) + # strangely, glx returns nil when -d:danger + # var attribList = [GlxRgba, GlxDepthSize, 24, GlxDoublebuffer] + # vi = display.glXChooseVisual(display.defaultScreen, attribList[0].addr)[] + + let cmap = display.XCreateColormap(root, vi.visual, AllocNone) + var swa = XSetWindowAttributes(colormap: cmap) + + result.handle = display.XCreateWindow( + root, + 0, 0, + size.x.cuint, size.y.cuint, + 0, + vi.depth.cuint, + InputOutput, + vi.visual, + CwColormap or CwEventMask or CwBorderPixel, + swa.addr + ) - let root = display.defaultRootWindow - - var vi: XVisualInfo - if style == Transparent: - display.XMatchVisualInfo(display.defaultScreen, 32, TrueColor, vi.addr) - else: - display.XMatchVisualInfo(display.defaultScreen, 24, TrueColor, vi.addr) - # strangely, glx returns nil when -d:danger - # var attribList = [GlxRgba, GlxDepthSize, 24, GlxDoublebuffer] - # vi = display.glXChooseVisual(display.defaultScreen, attribList[0].addr)[] - - let cmap = display.XCreateColormap(root, vi.visual, AllocNone) - var swa = XSetWindowAttributes(colormap: cmap) - - result.handle = display.XCreateWindow( - root, - 0, 0, - size.x.cuint, size.y.cuint, - 0, - vi.depth.cuint, - InputOutput, - vi.visual, - CwColormap or CwEventMask or CwBorderPixel, - swa.addr - ) - - display.XSelectInput( - result.handle, - ExposureMask or - KeyPressMask or - KeyReleaseMask or - PointerMotionMask or - ButtonPressMask or - ButtonReleaseMask or - StructureNotifyMask or - EnterWindowMask or - LeaveWindowMask or - FocusChangeMask or - PropertyChangeMask - ) + display.XSelectInput( + result.handle, + ExposureMask or + KeyPressMask or + KeyReleaseMask or + PointerMotionMask or + ButtonPressMask or + ButtonReleaseMask or + StructureNotifyMask or + EnterWindowMask or + LeaveWindowMask or + FocusChangeMask or + PropertyChangeMask + ) - var wmProtocols = [xaWMDeleteWindow, xaNetWMSyncRequest] - display.XSetWMProtocols( - result.handle, wmProtocols[0].addr, cint wmProtocols.len - ) + var wmProtocols = [xaWMDeleteWindow, xaNetWMSyncRequest] + display.XSetWMProtocols( + result.handle, wmProtocols[0].addr, cint wmProtocols.len + ) - result.im = display.XOpenIM - result.ic = result.im.XCreateIC( - "clientWindow", - result.handle, - "focusWindow", - result.handle, - "inputStyle", - XimPreeditNothing or XimStatusNothing, - nil - ) + result.im = display.XOpenIM + result.ic = result.im.XCreateIC( + "clientWindow", + result.handle, + "focusWindow", + result.handle, + "inputStyle", + XimPreeditNothing or XimStatusNothing, + nil + ) - var gcv: XGCValues - result.gc = display.XCreateGC(result.handle, GCForeground or GCBackground, gcv.addr) - - when not defined(useCpu): - result.ctx = display.glXCreateContext(vi.addr, nil, 1) - if result.ctx == nil: - raise WindyError.newException("Error creating OpenGL context") - - result.title = title - - # Set a default cursor - result.state.cursor = common.Cursor(kind: ArrowCursor) - result.applyCursor() - - # Enable XDnD awareness (version 5) - const xdndVersion = 5'u32 - result.handle.setProperty(xaXdndAware, xaCardinal, 32, @[xdndVersion].asString) - - when not defined(useCpu): - makeContextCurrent result - if vsync: - if glXSwapIntervalEXT != nil: - display.glXSwapIntervalEXT(result.handle, 1) - elif glXSwapIntervalMESA != nil: - glXSwapIntervalMESA(1) - elif glXSwapIntervalSGI != nil: - glXSwapIntervalSGI(1) - else: + var gcv: XGCValues + result.gc = display.XCreateGC(result.handle, GCForeground or GCBackground, gcv.addr) + + if linuxOptions.gpuPixels or not defined(useCpu): + result.ctx = display.glXCreateContext(vi.addr, nil, 1) + if result.ctx == nil: + raise WindyError.newException("Error creating OpenGL context") + + result.title = title + + # Set a default cursor + result.state.cursor = common.Cursor(kind: ArrowCursor) + result.applyCursor() + + # Enable XDnD awareness (version 5) + const xdndVersion = 5'u32 + result.handle.setProperty(xaXdndAware, xaCardinal, 32, @[xdndVersion].asString) + + if linuxOptions.gpuPixels: + makeContextCurrent result + let interval = if vsync: 1'i32 else: 0'i32 + var configured = false + if hasGlxExtension("GLX_EXT_swap_control"): + display.glXSwapIntervalEXT(result.handle, interval) + configured = true + elif hasGlxExtension("GLX_MESA_swap_control"): + if glXSwapIntervalMESA(interval.cuint) == 0: + configured = true + elif vsync and hasGlxExtension("GLX_SGI_swap_control"): + if glXSwapIntervalSGI(interval) == 0: + configured = true + if vsync and not configured: raise WindyError.newException("VSync is not supported") - if visible: - result.visible = true - - block xsync: - var vEv, vEr: cint - if display.XSyncQueryExtension(vEv.addr, vEr.addr): - var vMaj, vMin: cint - display.XSyncInitialize(vMaj.addr, vMin.addr) - result.xSyncCounter = display.XSyncCreateCounter(XSyncValue()) - result.handle.setProperty( - xaNetWMSyncRequestCounter, - xaCardinal, - 32, - @[result.xSyncCounter].asString - ) + result.gpuPixelVsyncState = vsync and configured + elif not defined(useCpu): + makeContextCurrent result + if vsync: + if glXSwapIntervalEXT != nil: + display.glXSwapIntervalEXT(result.handle, 1) + elif glXSwapIntervalMESA != nil: + discard glXSwapIntervalMESA(1) + elif glXSwapIntervalSGI != nil: + discard glXSwapIntervalSGI(1) + else: + raise WindyError.newException("VSync is not supported") + if visible: + result.visible = true + + block xsync: + var vEv, vEr: cint + if display.XSyncQueryExtension(vEv.addr, vEr.addr): + var vMaj, vMin: cint + display.XSyncInitialize(vMaj.addr, vMin.addr) + result.xSyncCounter = display.XSyncCreateCounter(XSyncValue()) + result.handle.setProperty( + xaNetWMSyncRequestCounter, + xaCardinal, + 32, + @[result.xSyncCounter].asString + ) - result.style = style + result.style = style - windows.add result + windows.add result + except CatchableError: + destroy result + raise proc pollEvents(window: Window) = diff --git a/src/windy/platforms/linux/x11/glx.nim b/src/windy/platforms/linux/x11/glx.nim index b626244f..518244a5 100644 --- a/src/windy/platforms/linux/x11/glx.nim +++ b/src/windy/platforms/linux/x11/glx.nim @@ -6,7 +6,13 @@ type const GlxRgba* = 4'i32 GlxDoublebuffer* = 5'i32 + GlxRedSize* = 8'i32 + GlxGreenSize* = 9'i32 + GlxBlueSize* = 10'i32 + GlxAlphaSize* = 11'i32 GlxDepthSize* = 12'i32 + GlxStencilSize* = 13'i32 + GlxSampleBuffers* = 100000'i32 const libGLX = when defined(linux): "libGL.so.1" @@ -28,7 +34,9 @@ proc glXGetCurrentContext*(): GlxContext {.libglx.} proc glXSwapBuffers*(d: Display, drawable: Drawable) {.libglx.} +proc glXQueryExtensionsString*(d: Display, screen: cint): cstring {.libglx.} + proc glXSwapIntervalEXT*(d: Display, drawable: Drawable, interval: cint) {.libglx.} -proc glXSwapIntervalMESA*(interval: cint) {.libglx.} -proc glXSwapIntervalSGI*(interval: cint) {.libglx.} +proc glXSwapIntervalMESA*(interval: cuint): cint {.libglx.} +proc glXSwapIntervalSGI*(interval: cint): cint {.libglx.} diff --git a/src/windy/x11pixels.nim b/src/windy/x11pixels.nim new file mode 100644 index 00000000..2100e149 --- /dev/null +++ b/src/windy/x11pixels.nim @@ -0,0 +1,173 @@ +import + std/[math, strformat], + opengl, pixie, + ../windy, + ./platforms/linux/native/pixelpair + +export PixelRegion + +type + X11PixelPresenter* = ref object + window: Window + texture, program, vertices: GLuint + textureWidth, textureHeight: int + closed: bool + +const + VertexSource = """#version 120 +attribute vec2 position; +attribute vec2 uv; +varying vec2 tex; +void main(){gl_Position=vec4(position,0.0,1.0);tex=uv;} +""" + FragmentSource = """#version 120 +varying vec2 tex; +uniform sampler2D image; +void main(){gl_FragColor=vec4(texture2D(image,tex).rgb,1.0);} +""" + MaximumLogBytes = 65536 + +proc checkGl(operation: string) = + ## Report driver errors at the operation that owns them. + let code = glGetError() + if code != GL_NO_ERROR: + raise newException(WindyError, &"{operation}: OpenGL error {code.uint32}.") + +proc compileShader(kind: GLenum, source: string): GLuint = + ## Compile a small pixel shader, preserving the driver's diagnostic on failure. + result = glCreateShader(kind) + var text = source.cstring + glShaderSource(result, 1, cast[cstringArray](addr text), nil) + glCompileShader(result) + var status: GLint + glGetShaderiv(result, GL_COMPILE_STATUS, addr status) + if status == 0: + var length: GLint + glGetShaderiv(result, GL_INFO_LOG_LENGTH, addr length) + var message = newString(clamp(length.int, 1, MaximumLogBytes)) + glGetShaderInfoLog(result, message.len.GLsizei, nil, message.cstring) + glDeleteShader(result) + raise newException(WindyError, "Compiling X11 pixel shader: " & message) + +proc close*(presenter: X11PixelPresenter) = + ## Release GPU storage before its window; repeated close is harmless. + if presenter == nil or presenter.closed: return + if presenter.window != nil and not presenter.window.closed: + presenter.window.makeContextCurrent() + if presenter.texture != 0: glDeleteTextures(1, addr presenter.texture) + if presenter.vertices != 0: glDeleteBuffers(1, addr presenter.vertices) + if presenter.program != 0: glDeleteProgram(presenter.program) + presenter.closed = true + presenter.window = nil + +proc newX11PixelPresenter*(window: Window): X11PixelPresenter = + ## Claim an opt-in double-buffered X11 GPU pixel window on the creating thread. + if window == nil or not window.hasGpuPixelContext: + raise newException(WindyError, "X11 GPU pixels require LinuxWindowOptions(gpuPixels: true).") + window.makeContextCurrent() + loadExtensions() + result = X11PixelPresenter(window: window) + try: + let vertex = compileShader(GL_VERTEX_SHADER, VertexSource) + defer: glDeleteShader(vertex) + let fragment = compileShader(GL_FRAGMENT_SHADER, FragmentSource) + defer: glDeleteShader(fragment) + result.program = glCreateProgram() + glAttachShader(result.program, vertex) + glAttachShader(result.program, fragment) + glBindAttribLocation(result.program, 0, "position") + glBindAttribLocation(result.program, 1, "uv") + glLinkProgram(result.program) + var linked: GLint + glGetProgramiv(result.program, GL_LINK_STATUS, addr linked) + if linked == 0: + var length: GLint + glGetProgramiv(result.program, GL_INFO_LOG_LENGTH, addr length) + var message = newString(clamp(length.int, 1, MaximumLogBytes)) + glGetProgramInfoLog(result.program, message.len.GLsizei, nil, message.cstring) + raise newException(WindyError, "Linking X11 pixel shaders: " & message) + glGenTextures(1, addr result.texture) + glGenBuffers(1, addr result.vertices) + glBindTexture(GL_TEXTURE_2D, result.texture) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST.GLint) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST.GLint) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE.GLint) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE.GLint) + checkGl("Initializing X11 pixel presenter") + except: + result.close() + raise + +proc drawPixels*(presenter: X11PixelPresenter, image: Image, region: PixelRegion, + changed = true, scale = 0, fitDown = false) = + ## Upload opaque native RGBA and draw one integer-scaled crop into the back buffer. + if presenter == nil or presenter.closed or presenter.window.closed: + raise newException(WindyError, "X11 pixel presenter is closed.") + if scale < 0: raise newException(ValueError, "Pixel scale cannot be negative.") + let + width = if image == nil: 0 else: image.width + height = if image == nil: 0 else: image.height + size = presenter.window.framebufferSize + validateRegions(width, height, [region, PixelRegion()]) + if size.x <= 0 or size.y <= 0: return + presenter.window.makeContextCurrent() + glViewport(0, 0, size.x.GLsizei, size.y.GLsizei) + glDisable(GL_BLEND) + glDisable(GL_DEPTH_TEST) + glDisable(GL_STENCIL_TEST) + glDisable(GL_SCISSOR_TEST) + glDisable(GL_DITHER) + glClearColor(0, 0, 0, 1) + glClear(GL_COLOR_BUFFER_BIT) + if region != PixelRegion(): + var viewport = pixelViewport(region, size.x.int, size.y.int) + if scale > 0: + if size.x.int div region.width < scale or size.y.int div region.height < scale: + raise newException(ValueError, "Window is too small for the requested scale.") + viewport = PixelRegion(width: region.width * scale, height: region.height * scale) + viewport.x = (size.x.int - viewport.width) div 2 + viewport.y = (size.y.int - viewport.height) div 2 + elif fitDown and (region.width > size.x.int or region.height > size.y.int): + let factor = min(size.x.float64 / region.width.float64, size.y.float64 / region.height.float64) + viewport.width = max(1, floor(region.width.float64 * factor).int) + viewport.height = max(1, floor(region.height.float64 * factor).int) + viewport.x = (size.x.int - viewport.width) div 2 + viewport.y = (size.y.int - viewport.height) div 2 + glActiveTexture(GL_TEXTURE0) + glBindTexture(GL_TEXTURE_2D, presenter.texture) + glPixelStorei(GL_UNPACK_ALIGNMENT, 1) + if width != presenter.textureWidth or height != presenter.textureHeight: + static: doAssert sizeof(ColorRGBX) == 4 + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8.GLint, width.GLsizei, height.GLsizei, + 0, GL_RGBA, GL_UNSIGNED_BYTE, unsafeAddr image.data[0]) + presenter.textureWidth = width + presenter.textureHeight = height + elif changed: + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width.GLsizei, height.GLsizei, + GL_RGBA, GL_UNSIGNED_BYTE, unsafeAddr image.data[0]) + var data = pixelVertices(width, height, [region, PixelRegion()]) + glUseProgram(presenter.program) + glUniform1i(glGetUniformLocation(presenter.program, "image"), 0) + glBindBuffer(GL_ARRAY_BUFFER, presenter.vertices) + glBufferData(GL_ARRAY_BUFFER, 24 * sizeof(cfloat), addr data[0], GL_STREAM_DRAW) + glVertexAttribPointer(0, 2, cGL_FLOAT, GL_FALSE, (4 * sizeof(cfloat)).GLsizei, nil) + glVertexAttribPointer(1, 2, cGL_FLOAT, GL_FALSE, (4 * sizeof(cfloat)).GLsizei, + cast[pointer](2 * sizeof(cfloat))) + glEnableVertexAttribArray(0) + glEnableVertexAttribArray(1) + glViewport(viewport.x.GLint, (size.y.int - viewport.y - viewport.height).GLint, + viewport.width.GLsizei, viewport.height.GLsizei) + glDrawArrays(GL_TRIANGLES, 0, 6) + glDisableVertexAttribArray(0) + glDisableVertexAttribArray(1) + glUseProgram(0) + glBindBuffer(GL_ARRAY_BUFFER, 0) + glFlush() + checkGl("Drawing X11 pixels") + +proc swap*(presenter: X11PixelPresenter) = + ## Swap a completed back buffer after all windows in the frame have been drawn. + if presenter == nil or presenter.closed or presenter.window.closed: + raise newException(WindyError, "X11 pixel presenter is closed.") + presenter.window.makeContextCurrent() + presenter.window.swapBuffers() diff --git a/tests/config.nims b/tests/config.nims index 5c141732..4571b14d 100644 --- a/tests/config.nims +++ b/tests/config.nims @@ -9,6 +9,9 @@ if projectName() in ["manual_drm_gpu", "manual_drm_pixelpair"] and not defined(d switch("define", "release") switch("define", "danger") let testBackends = getEnv("WINDY_TEST_BACKENDS") +if projectName() == "test_x11_pixels": + switch("define", "useCpu") + switch("define", "windyBackends=" & (if testBackends.len > 0: testBackends else: "x11")) if projectName() == "test_drm_pixelpair" and testBackends.len == 0: switch("define", "useCpu") switch("define", "windyBackends=drm") diff --git a/tests/test_x11_pixels.nim b/tests/test_x11_pixels.nim new file mode 100644 index 00000000..26d2afee --- /dev/null +++ b/tests/test_x11_pixels.nim @@ -0,0 +1,131 @@ +import + std/[monotimes, os, osproc, streams, strutils, times, unittest], + opengl, pixie, windy, + windy/x11pixels + +proc readBack(window: Window): Image = + ## Read only synthetic pixels from the back buffer on the isolated test display. + window.makeContextCurrent() + let size = window.framebufferSize + result = newImage(size.x, size.y) + glReadBuffer(GL_BACK) + glPixelStorei(GL_PACK_ALIGNMENT, 1) + glReadPixels(0, 0, size.x.GLsizei, size.y.GLsizei, GL_RGBA, GL_UNSIGNED_BYTE, + addr result.data[0]) + doAssert glGetError() == GL_NO_ERROR + for y in 0 ..< result.height div 2: + for x in 0 ..< result.width: + swap(result.data[y * result.width + x], + result.data[(result.height - 1 - y) * result.width + x]) + +proc checkCrop(presenter: X11PixelPresenter, window: Window, source: Image, + crop: PixelRegion, scale = 0, changed = true) = + ## Compare every GPU output pixel with an independent nearest-neighbor CPU reference. + presenter.drawPixels(source, crop, changed, scale) + let + output = readBack(window) + factor = if scale > 0: scale else: + max(1, min(output.width div crop.width, output.height div crop.height)) + left = (output.width - crop.width * factor) div 2 + top = (output.height - crop.height * factor) div 2 + for y in 0 ..< output.height: + for x in 0 ..< output.width: + let + sx = (x - left) div factor + sy = (y - top) div factor + var expected = rgbx(0, 0, 0, 255) + if x >= left and y >= top and x < left + crop.width * factor and + y < top + crop.height * factor: + let pixel = source[crop.x + sx, crop.y + sy] + expected = rgbx(pixel.r, pixel.g, pixel.b, 255) + doAssert output[x, y] == expected, + "GPU crop/orientation/scale/opacity differed at " & $x & "," & $y + +proc main() = + ## Exercise real GLX contexts under a private Xvfb server, never the user's desktop. + let + executable = findExe("Xvfb") + oldDisplay = getEnv("DISPLAY") + oldSoftware = getEnv("LIBGL_ALWAYS_SOFTWARE") + doAssert executable.len > 0, "Xvfb is required for this headless-only test." + let server = startProcess(executable, args = ["-displayfd", "1", "-screen", "0", + "1280x1024x24", "+extension", "GLX", "-nolisten", "tcp"], options = {}) + defer: + if server.running: server.terminate() + discard server.waitForExit(5000) + server.close() + putEnv("DISPLAY", oldDisplay) + if oldSoftware.len > 0: putEnv("LIBGL_ALWAYS_SOFTWARE", oldSoftware) + else: delEnv("LIBGL_ALWAYS_SOFTWARE") + let number = server.outputStream.readLine().strip() + doAssert number.len > 0 and number.allCharsInSet({'0' .. '9'}) + putEnv("DISPLAY", ":" & number) + putEnv("LIBGL_ALWAYS_SOFTWARE", "1") + block: + let cpu = newWindow("Headless CPU", ivec2(8, 8)) + doAssert not cpu.hasGpuPixelContext and not cpu.gpuPixelVsync + cpu.close() + let + first = newWindow("Headless GPU first", ivec2(67, 55), vsync = false, + linuxOptions = LinuxWindowOptions(gpuPixels: true)) + second = newWindow("Headless GPU second", ivec2(83, 69), vsync = false, + linuxOptions = LinuxWindowOptions(gpuPixels: true)) + defer: first.close() + defer: second.close() + let + a = newX11PixelPresenter(first) + b = newX11PixelPresenter(second) + source = newImage(17, 19) + defer: a.close() + defer: b.close() + for y in 0 ..< source.height: + for x in 0 ..< source.width: + source[x, y] = ColorRGBX(r: uint8(x * 13), g: uint8(y * 11), + b: uint8((x + y) * 7), a: 0) + let + top = PixelRegion(x: 1, y: 2, width: 13, height: 7) + bottom = PixelRegion(x: 3, y: 11, width: 11, height: 8) + for frame in 0 ..< 5: + checkCrop(a, first, source, top, changed = frame == 0) + checkCrop(b, second, source, bottom, changed = frame == 0) + a.swap() + b.swap() + let larger = newImage(29, 31) + larger.fill(rgbx(17, 31, 173, 255)) + checkCrop(a, first, larger, bottom, changed = false) + checkCrop(a, first, source, top, changed = false) + checkCrop(a, first, source, top, scale = 2, changed = false) + expect ValueError: a.drawPixels(source, top, scale = 100) + expect ValueError: a.drawPixels(source, PixelRegion(x: 18, width: 1, height: 1)) + first.size = ivec2(7, 5) + pollEvents() + a.drawPixels(source, top, changed = false, fitDown = true) + let fitted = readBack(first) + for y in 0 ..< fitted.height: + for x in 0 ..< fitted.width: + var expected = rgbx(0, 0, 0, 255) + if y in 1 .. 3: + let pixel = source[top.x + (2 * x + 1) * top.width div 14, + top.y + (2 * (y - 1) + 1) * top.height div 6] + expected = rgbx(pixel.r, pixel.g, pixel.b, 255) + doAssert fitted[x, y] == expected + first.size = ivec2(67, 55) + pollEvents() + let start = getMonoTime() + for frame in 0 ..< 60: + a.drawPixels(source, top, false) + b.drawPixels(source, bottom, false) + a.swap() + b.swap() + doAssert (getMonoTime() - start).inSeconds < 10, "Paired submission stalled." + a.drawPixels(nil, PixelRegion()) + for pixel in readBack(first).data: doAssert pixel == rgbx(0, 0, 0, 255) + a.close() + a.close() + first.close() + first.close() + b.close() + second.close() + second.close() + +main() From e4280e7d32d3fdae190fe537d034e5fe7d1b0049 Mon Sep 17 00:00:00 2001 From: monofuel Date: Wed, 11 Nov 2026 15:05:39 -0500 Subject: [PATCH 18/21] Organize Linux pixel APIs and simplify display documentation --- docs/drm-gpu-proof.md | 120 ------------------ docs/drm-pixel-pairs.md | 88 +++++++------ docs/linux-direct.md | 75 +++++------ docs/x11-pixels.md | 89 +++++++------ src/windy/platforms/linux/direct.nim | 3 +- .../linux/{native/drm.nim => drm/display.nim} | 4 +- .../linux/{native/drmgpu.nim => drm/gpu.nim} | 0 src/windy/{ => platforms/linux}/drmpixels.nim | 7 +- src/windy/platforms/linux/native/state.nim | 3 +- src/windy/{ => platforms/linux}/x11pixels.nim | 4 +- tests/bench_native_upload.nim | 2 +- tests/drm_gpu_patterns.nim | 2 +- tests/manual_drm_gpu.nim | 2 +- tests/manual_drm_pixelpair.nim | 2 +- tests/test_drm_pixelpair.nim | 3 +- tests/test_linux_drm.nim | 2 +- tests/test_native_input.nim | 3 +- tests/test_x11_pixels.nim | 2 +- 18 files changed, 144 insertions(+), 267 deletions(-) delete mode 100644 docs/drm-gpu-proof.md rename src/windy/platforms/linux/{native/drm.nim => drm/display.nim} (99%) rename src/windy/platforms/linux/{native/drmgpu.nim => drm/gpu.nim} (100%) rename src/windy/{ => platforms/linux}/drmpixels.nim (92%) rename src/windy/{ => platforms/linux}/x11pixels.nim (99%) diff --git a/docs/drm-gpu-proof.md b/docs/drm-gpu-proof.md deleted file mode 100644 index b73a2322..00000000 --- a/docs/drm-gpu-proof.md +++ /dev/null @@ -1,120 +0,0 @@ -# Standalone DRM/GBM/EGL proof - -`tests/manual_drm_gpu.nim` is an experiment, not a new Windy rendering API. -The production X11, fbdev and CPU DRM backends are unchanged. Its private -bindings use the existing libdrm loader and dynamically load libgbm, libEGL and -libGLESv2. It requires a 64-bit little-endian Linux system with two equally sized -connected outputs, hardware Mesa rendering and exclusive display ownership. - -Compile with `nim c tests/manual_drm_gpu.nim`. Its project-specific test -configuration enables release/danger unless `debug` is defined. For ARM64 use -the existing `WINDY_TEST_TARGET=arm64` and `MONO_RETRO_CC` configuration. No new -Nim dependency is needed. - -``` -manual_drm_gpu CARD TOP_CONNECTOR BOTTOM_CONNECTOR ds|earthbound xbgr|xrgb gpu|cpu|inspect [seconds] -``` - -`inspect` initializes EGL, renders and checks pixels, but never requests DRM -master or changes scanout. Other modes temporarily own both displays. Stop the -display service before running them; use an outer timeout and restore the service -on exit. Duration defaults to eight seconds and accepts only 1..8. Initialization -and cleanup are additional to the measured loop, so allow a small watchdog grace. -Do not run beside games, compiler jobs or other performance tests. - -## Rendering and paired presentation - -- One native-size combined texture contains both source panels. XBGR uploads - opaque R,G,B,255 bytes; XRGB uploads B,G,R,255. A shader handles the latter's - channel interpretation without changing the source buffer on the CPU. -- One linear XRGB GBM surface contains two full-resolution output regions. - EGL selection requires zero depth, stencil, samples and sample buffers; - `eglChooseConfig` minimum sizes alone do not exclude multisampling. - Nearest-neighbour quads enlarge each source region by an integer and clear the - remaining border to black. The `ds` layout uses 256×192 per panel; `earthbound` - uses 256×224. Panel dimensions come from DRM, not board identifiers. -- `inspect` compares every rendered pixel against the integer-scaled pattern, - including both source regions and borders. This verifies GPU rendering; - physical screen order/orientation and scanout still need manual observation. -- Primary planes crop the two output regions at 1:1. Initial activation uses - one atomic TEST_ONLY followed by one blocking modeset. Unsupported shared - scanout is an error; there is no automatic second backend. -- Subsequent updates change both plane framebuffer IDs in one nonblocking, - vblank-synchronized atomic commit. Version-three DRM events identify each CRTC. - The previous GBM buffer is retained until both events arrive, in either order. - There is at most one pending paired commit. -- `glFinish` explicitly completes rendering before KMS sees the buffer. Its - cost is measured. Fence-based overlap is future work, not implied by this proof. -- Completion timestamps measure software-visible skew between outputs; a single - atomic transaction does not guarantee physically phase-locked panels. The - moving white markers on both panels represent the same synthetic frame. - -## Comparison and recovery - -The CPU mode uses the same source animation, row expansion/row replication and -border-only clears, then the existing full-resolution DRM upload/page-flip path. -It is a synthetic presentation reference, not an emulator benchmark. Pixel -conversion is scalar in this diagnostic; the player's XRGB converter uses SIMD. -Unchanged-panel caching is intentionally absent because the pattern moves. - -Reports include generation, upload, rendering/finish, submission and completion -waits, mean/p95/max, main-thread CPU time per frame, frames over 20 ms and paired -completion skew. GPU rendering also separates draw calls, EGL swap, GL finish -and GBM acquisition. CPU submission includes full-panel uploads. GPU rendering -includes EGL swap/GBM acquisition. Never compare the two labels as isolated -equivalent operations; compare main-thread CPU and the complete pipeline. -Nim's `cpuTime` uses the calling thread on Linux; these counters exclude Mesa -background threads and are not whole-process CPU measurements. - -Normal exit restores the captured plane/connector/CRTC state before releasing -buffers. Cleanup errors are reported while remaining resources are still -released. If atomic restoration fails, outputs are disabled before freeing GPU -storage. An outer service-restoring wrapper is needed for fatal signals or a -watchdog kill; Nim defers cannot handle SIGKILL. - -Run `nim r tests/test_drm_gpu_patterns.nim` for colours, both layouts, CPU output, -both completion orders and rejected duplicate/unexpected events. Existing -`tests/test_linux_drm.nim` covers the unchanged CPU DRM lifecycle. - -## RG DS Plus results - -Tested with Linux `7.0.2-mono-rgdsplus-rocknix-r3`, Mesa 26.1.8 and hardware -`Mali-G52 r1 MC1 (Panfrost)`, OpenGL ES 3.1. DSI-2 is top (CRTC70/plane39), -DSI-1 bottom (CRTC69/plane33). No X11 or Wayland is involved. Gentoo compiled -Mesa through the MLE `arm64-rgdsplus` distcc target; memory-heavy local source -generation needed temporary swap, removed before these measurements. - -Every GPU pixel and black border passes exact readback for both XRGB and XBGR -in both layouts. One shared 1024×1536 linear framebuffer passes atomic TEST_ONLY -and real paired flips. Both CRTC events arrive and cleanup restores the launcher. -The synthetic animation and all tests are silent. Monofuel manually confirms -both full-panel 4× and bordered 3× patterns look correct: upright, crisp, correct -screen order, smooth markers and no visible moving tears. - -Matched eight-second runs with the same shipped ARM64 diagnostic: - -| Source per panel / input | CPU reference main-thread ms/frame | GPU main-thread ms/frame | CPU / GPU frames | CPU / GPU frames over 20 ms | -| --- | ---: | ---: | ---: | ---: | -| 256×192 / XBGR, 4× full panel | 7.605 | 1.935 | 481 / 480 | 1 / 2 | -| 256×224 / XBGR, 3× with borders | 7.603 | 2.118 | 481 / 481 | 2 / 2 | - -All runs are approximately 60 FPS. Main-thread time includes synthetic frame generation; -GPU presentation reduces that time by roughly 72–75%. These are presentation -tests, not emulator benchmarks or measurements of player integration. Initial -activation is included; no warmup frames are discarded. - -GPU upload averages 0.256–0.300 ms and render/swap/finish about 5.0–5.1 ms, -mostly GPU completion wait. XRGB also passes real paired scanout in both layouts -at approximately 60 FPS, with 2.063 / 2.251 CPU ms/frame respectively. Completion -timestamp skew settles around 6.1 ms: the panels show one coordinated generation, -but their physical refresh phases remain offset. - -The first configuration selected the last matching EGL visual, which had 24-bit -depth, 8-bit stencil and 16× MSAA. GPU completion then took about 78 ms and output -ran near 11 FPS despite 800 MHz GPU clocks. Explicitly selecting zero-sized -auxiliary buffers removes that work. The diagnostic reports the selected sizes -and fails if that configuration is unavailable. - -The production Windy backends and player remain unchanged. Next integration must -preserve frame ownership, both completion events, exact pixel scaling and X11/ -fbdev behavior; conservative `glFinish` can be revisited separately. diff --git a/docs/drm-pixel-pairs.md b/docs/drm-pixel-pairs.md index 26ba371d..1fcfacc5 100644 --- a/docs/drm-pixel-pairs.md +++ b/docs/drm-pixel-pairs.md @@ -1,53 +1,57 @@ -# GPU pixel pairs +# DRM GPU pixels -`windy/drmpixels` adds GPU-scaled pixel presentation for two native DRM windows. -The caller supplies their order and source crops; device identities and game -layouts remain application concerns. Existing X11/fbdev/CPU rendering is retained. +Use a DRM pixel pair when your app needs to scale images on the GPU and update +two displays together. Windy uploads one Pixie image, draws a region of it for +each display, and submits both displays in one atomic DRM update. -Enable Windy's DRM backend in the project's Nim configuration. Create two equal, -unrotated native windows on one DRM device with independent controllers, then -claim their pair before presenting CPU pixels to either window: +The displays must be equally sized, unrotated, and on the same DRM device. This +requires hardware GPU rendering, GBM, EGL and OpenGL ES. Enable `useCpu` and the +`drm` backend in `config.nims` as shown in [the display setup](linux-direct.md). + +This example displays the top and bottom halves of an image on two connectors: ```nim import pixie, windy, - windy/drmpixels + windy/platforms/linux/drmpixels -proc showPanels(first, second: Window, frame: Image) = - ## Present two stacked panels without splitting their pixel storage. +proc main() = + ## Display one image across two DRM outputs. + setLinuxBackend(DrmBackend) let - pair = newDrmPixelPair(first, second) - panelHeight = frame.height div 2 + first = newWindow("First", ivec2(0, 0), + linuxOptions = LinuxWindowOptions(device: "/dev/dri/card0", output: "DSI-1")) + second = newWindow("Second", ivec2(0, 0), + linuxOptions = LinuxWindowOptions(device: "/dev/dri/card0", output: "DSI-2")) + defer: first.close() + defer: second.close() + + let pair = newDrmPixelPair(first, second) defer: pair.close() - pair.presentPixels(frame, [ - PixelRegion(width: frame.width, height: panelHeight), - PixelRegion(y: panelHeight, width: frame.width, height: panelHeight)]) + let image = newImage(256, 384) + image.fill(color(0.2, 0.4, 0.8, 1)) + while not first.closeRequested and not second.closeRequested: + pollEvents() + pair.presentPixels(image, [ + PixelRegion(width: 256, height: 192), + PixelRegion(y: 192, width: 256, height: 192)]) + +main() ``` -Keep the pair alive across frames. Source coordinates use the top-left origin; -a default/zero region leaves an output black. Scaling uses the largest positive -integer that fits, centered with black borders. Below native size it stays at1× -and clips to its output. Odd borders match CPU top/left centering. - -The packed Pixie RGBA image is read only during the call. Crops share one texture -upload and one linear combined GBM framebuffer. There is no CPU enlargement, -screen splitting or readback in normal presentation. EGL has no depth/stencil -or MSAA. GBM/EGL/GLES libraries load only when creating a pair; software renderers -are rejected. Unsupported paired scanout does not choose another backend. - -Both primary-plane framebuffer IDs change in one atomic transaction. Rendering -finishes before submission; old storage remains locked until both CRTC events -arrive. This coordinates generations, not physical panel refresh phases. -Presentation is synchronous and vblank paced; fencing/overlap is separate work. - -Use the creating thread. The pair activates its EGL context for rendering and -cleanup. Keep windows alive for input and close the pair before either window. -Claiming releases their unused CPU scanout buffers; reopen windows to return to -CPU presentation. Closed pairs reject presentation and tolerate repeated close. -Initialization and cleanup errors propagate. - -`tests/test_drm_pixelpair.nim` covers geometry, crops, event order and ownership. -`tests/manual_drm_pixelpair.nim CARD TOP BOTTOM` reads every GPU pixel and tests -real flips, odd borders, resizing, clipping, black outputs and swaps. Physical -tests need exclusive display ownership and a bounded service-restoring runner. -Initial measurements are in [the proof guide](drm-gpu-proof.md). +Choose the connector order for your app. Region coordinates start at the image's +top-left corner. Each region is scaled by a whole number and centered, with black +borders. An empty region leaves that display black. Images larger than a display +are shown at 1× and clipped. + +Create the pair before calling either window's CPU `presentPixels`. Keep it alive +across frames, use it on the window thread, and close it before closing the windows. +The windows still handle input while the pair handles rendering. + +`presentPixels` waits for both displays to finish their page flips before allowing +the old buffer to be reused. Both outputs receive the same frame update, although +the physical panels may refresh at different times. Unsupported hardware raises +an error; the pair does not automatically switch to CPU rendering. + +Run `nim r tests/test_drm_pixelpair.nim` for the headless tests. The hardware test +is `tests/manual_drm_pixelpair.nim`; it needs exclusive access to both displays. diff --git a/docs/linux-direct.md b/docs/linux-direct.md index 8d0bae26..400e6b6a 100644 --- a/docs/linux-direct.md +++ b/docs/linux-direct.md @@ -1,20 +1,21 @@ -# Direct Linux displays (experimental) +# Linux displays without X11 -These CPU pixel backends support Linux systems without X11. The default build -still uses X11/OpenGL. Put the desired settings in your application's -`config.nims`: +Windy can draw Pixie images directly to a Linux display through fbdev or DRM. +These backends are experimental and currently require a 64-bit Linux target. +Your app needs access to the display and input devices, with no other application +controlling the display. + +Put the backends you need in your application's `config.nims`: ```nim switch("define", "useCpu") switch("define", "windyBackends=fbdev,drm") ``` -`windyBackends` accepts `x11`, `fbdev`, `drm`, or a comma-separated combination. -Direct-only builds do not need X11 libraries. DRM loads `libdrm.so.2` when opened -and currently requires a 64-bit target. Combined builds include X11's library -dependencies even when selecting a direct display. +You can include `x11` in the list for an app that also runs on a desktop. +Builds without `x11` do not need X11 libraries. DRM needs `libdrm.so.2` at runtime. -Select the backend before creating windows: +Choose the backend before opening a window. For example, to fill a DRM display: ```nim import pixie, windy @@ -33,45 +34,27 @@ while not window.closeRequested: window.close() ``` -Choose connector and input paths for your system. Multiple DRM windows can own -separate connectors on the same device. For fbdev, select `FbdevBackend` and -`LinuxWindowOptions(device: "/dev/fb0")`; only one window can own that framebuffer. -Windows use the panel's native size. `rotation` and `touchRotation` accept -0, 90, 180 or 270 degrees. Device discovery and board-specific configuration -belong to the application. - -`window.onTouch` receives begin/move/end/cancel events with stable contact IDs -and window-local pixel positions. `window.touches` contains active contacts. -Evdev single-touch and slot-based multitouch are supported, without smoothing, -gestures or mouse synthesis. X11, browser, Windows and macOS touch delivery is -not implemented in this draft. +Replace the device, connector and touch paths with those for your system. Omit +`touchDevice` if you do not need touch. Each window fills its display; the size +passed to `newWindow` is ignored. You can open separate windows on different DRM +connectors on the same device. -The application needs permission to open its display and input devices, and -exclusive display ownership. Close windows before switching backends. Native -desktop operations such as resizing, clipboard access or OpenGL contexts raise -`WindyError`. Keyboard, mouse and controller input are not supplied by these -direct backends; use the application's input library for controllers. +For fbdev, choose `FbdevBackend` and open a window with `vsync = false` and +`LinuxWindowOptions(device: "/dev/fb0")`. Only one window can use that framebuffer. +Both backends accept `rotation` and `touchRotation` of 0, 90, 180 or 270 degrees. -DRM windows with `vsync=true` use two buffers. Presentation uploads into the -non-visible buffer and queues a switch at vblank. A subsequent upload waits for -the previous switch before reusing storage, with at most one pending flip per -screen. Outputs remain independent; the backend dispatches completion events for -all windows sharing the DRM device. Applications still control their frame rate. -Page-flip errors and a one-second completion timeout propagate to the caller. -Each additional buffer uses the panel's native width, pitch and height (about -3 MiB for a 1024×768 32-bit panel). +DRM's `vsync = true` draws into a second buffer and switches buffers between +refreshes to avoid tearing. Without vsync, and on fbdev, writes go straight to the +displayed buffer and can tear. Close all windows before switching backends. -DRM with `vsync=false` and fbdev use direct scanout writes, which can tear. -fbdev requires `vsync=false`. Normal close waits for pending DRM flips and restores -the saved display state when it is still available. If the kernel retired the -previous DRM framebuffer, the output is disabled instead. +Touch events arrive through `window.onTouch`; `window.touches` holds the active +contacts. Positions are in window pixels. Both single-touch and multitouch evdev +devices are supported. Touch is not yet implemented for X11 or the other platforms. -Run `nim r tests/test_native_input.nim` for touch lifecycle and pixel tests. -`WINDY_TEST_BACKENDS=x11,fbdev,drm nim r tests/test_linux_display.nim` checks -combined backend selection and X11 CPU pixels; use an isolated Xvfb display. -`nim r tests/test_linux_glx.nim` checks the default OpenGL path. +These backends do not provide keyboard, mouse or controller input. Desktop +operations such as resizing and clipboard access are also unavailable. Device +selection and controller input belong in the application. -On a dedicated test device, compile `tests/manual_direct_displays.nim` with -`WINDY_TEST_BACKENDS=fbdev,drm` and run it with `fbdev`, or with -`drm CONNECTOR1 CONNECTOR2`. This temporarily changes both outputs and checks -independent ownership and cleanup; stop other display applications first. +For GPU scaling and coordinated updates to two displays, see +[DRM GPU pixels](drm-pixel-pairs.md). Desktop GPU rendering is covered in +[X11 GPU pixels](x11-pixels.md). diff --git a/docs/x11-pixels.md b/docs/x11-pixels.md index fb2d0f01..ef173ae7 100644 --- a/docs/x11-pixels.md +++ b/docs/x11-pixels.md @@ -1,44 +1,49 @@ # X11 GPU pixels -`windy/x11pixels` presents opaque Pixie images through a double-buffered GLX -drawable, including in `-d:useCpu` applications. It leaves ordinary CPU windows -and native DRM/fbdev presentation unchanged. Opt in with -`LinuxWindowOptions(gpuPixels: true)` on `newWindow`. - -Create a `newX11PixelPresenter(window)`, call `drawPixels(image, region)` then -`swap()`, and close the presenter before its window. Create, draw, swap and close -on the window thread. Each presenter owns one texture, shader program and vertex -buffer. The context needs OpenGL 2.1 / GLSL 1.20. - -Images upload as RGBA bytes without channel conversion; the shader forces opaque -output. Native-size textures use nearest filtering, integer scaling and centered -black borders. An explicit `scale` must fit. `fitDown` permits fractional nearest -scaling for a modal UI in a small window; leave it false for game pixels. Regions -use top-left coordinates. A nil image with an empty region clears black. - -`changed = false` retains texture pixels; callers must still draw after resizes -or crop changes. A different source size reallocates storage even when `changed` -is false. For two windows, draw both back buffers first and then swap both. This -submits drawing before presentation waits; it does not promise atomic updates -across windows. - -`vsync = true` requests swap interval one through the advertised EXT, MESA or SGI -GLX extension. Startup fails if no supported extension can configure it. -`gpuPixelVsync` reports successful configuration, not measured physical refresh. -`vsync = false` is an explicit diagnostic/headless mode. `hasGpuPixelContext` -checks the opt-in drawable; CPU `presentPixels` is not its presentation API. -GPU initialization and driver errors remain visible to callers. - -## Verification - -Run `nim r tests/test_x11_pixels.nim` with Xvfb installed. It starts and stops its -own isolated server and uses software Mesa, never the user's desktop. Actual GL -readback is compared against an independent CPU pixel reference for colours, -opacity, crop orientation, borders, forced scale, UI downscale, duplicates, -source-size changes and independent contexts. Cleanup and paired submission are -also checked. `nim r tests/test_drm_pixelpair.nim` retains the headless native checks. - -Xvfb does not provide physical vsync. Real-display acceptance must check moving -imagery for tearing and normal pacing with both windows visible, resized, -minimized and restored. Applications should retain an explicit CPU comparison -path. Automated playback remains muted or uses a null audio backend. +Use the X11 pixel presenter to scale a Pixie image on the GPU, with optional vsync. +It works in CPU pixel builds, so an app can choose between CPU and GPU rendering. +Add `switch("define", "useCpu")` to your application's `config.nims`. +The default Linux backend is X11. + +```nim +import + pixie, windy, + windy/platforms/linux/x11pixels + +proc main() = + ## Draw a Pixie image with the X11 GPU presenter. + let window = newWindow("GPU pixels", ivec2(800, 600), vsync = true, + linuxOptions = LinuxWindowOptions(gpuPixels: true)) + defer: window.close() + let presenter = newX11PixelPresenter(window) + defer: presenter.close() + let image = newImage(256, 224) + image.fill(color(0.2, 0.4, 0.8, 1)) + while not window.closeRequested: + pollEvents() + presenter.drawPixels(image, PixelRegion(width: image.width, height: image.height)) + presenter.swap() + +main() +``` + +The region uses coordinates from the image's top-left corner. Pixels are drawn +opaque, scaled by a whole number and centered with black borders. You can request +an exact size with `scale = 2`, or allow shrinking into a small window with +`fitDown = true`. + +Pass `changed = false` when the image has not changed to avoid uploading it again. +You must still draw and swap after a window resize or a change to the region. +To clear the window to black, pass a nil image and an empty `PixelRegion()`. + +Keep the presenter on the window thread and close it before closing the window. +It requires OpenGL 2.1. With `vsync = true`, startup fails if the GLX driver cannot +configure vsync; use `vsync = false` when you do not need it. + +For two windows, draw both before swapping either. X11 does not guarantee an +atomic update across windows. For coordinated updates on direct Linux displays, +see [DRM GPU pixels](drm-pixel-pairs.md). + +Run `nim r tests/test_x11_pixels.nim` for the headless rendering test. It requires +Xvfb and Mesa and checks the rendered pixels. Physical vsync still needs testing +on a real display. diff --git a/src/windy/platforms/linux/direct.nim b/src/windy/platforms/linux/direct.nim index 703decde..4e05d4de 100644 --- a/src/windy/platforms/linux/direct.nim +++ b/src/windy/platforms/linux/direct.nim @@ -2,7 +2,8 @@ import pixie, ../../common, ./backends, - ./native/[state, drm, framebuffer, touch] + ./drm/display, + ./native/[state, framebuffer, touch] when HasX11: import ./x11 diff --git a/src/windy/platforms/linux/native/drm.nim b/src/windy/platforms/linux/drm/display.nim similarity index 99% rename from src/windy/platforms/linux/native/drm.nim rename to src/windy/platforms/linux/drm/display.nim index e2d441a5..124a1db9 100644 --- a/src/windy/platforms/linux/native/drm.nim +++ b/src/windy/platforms/linux/drm/display.nim @@ -1,7 +1,7 @@ import std/[dynlib, monotimes, os, posix, strformat, strutils, times], pixie, - ./[framebuffer, pixels, pixelpair] + ../native/[framebuffer, pixels, pixelpair] type ModeInfo = object @@ -541,4 +541,4 @@ proc openDrm*(path: string): DrmDisplay = result.close() raiseOSError(error, path) -include drmgpu +include gpu diff --git a/src/windy/platforms/linux/native/drmgpu.nim b/src/windy/platforms/linux/drm/gpu.nim similarity index 100% rename from src/windy/platforms/linux/native/drmgpu.nim rename to src/windy/platforms/linux/drm/gpu.nim diff --git a/src/windy/drmpixels.nim b/src/windy/platforms/linux/drmpixels.nim similarity index 92% rename from src/windy/drmpixels.nim rename to src/windy/platforms/linux/drmpixels.nim index 950f4b18..b4258a1a 100644 --- a/src/windy/drmpixels.nim +++ b/src/windy/platforms/linux/drmpixels.nim @@ -1,8 +1,9 @@ import pixie, - ./common, - ./platforms/linux/[backends, direct], - ./platforms/linux/native/[drm, pixelpair] + ../../common, + ./[backends, direct], + ./drm/display, + ./native/pixelpair export PixelRegion diff --git a/src/windy/platforms/linux/native/state.nim b/src/windy/platforms/linux/native/state.nim index 356d7199..143b7a89 100644 --- a/src/windy/platforms/linux/native/state.nim +++ b/src/windy/platforms/linux/native/state.nim @@ -1,7 +1,8 @@ import ../../../common, ../backends, - ./[drm, framebuffer, touch] + ../drm/display, + ./[framebuffer, touch] type DirectWindowState* = ref object diff --git a/src/windy/x11pixels.nim b/src/windy/platforms/linux/x11pixels.nim similarity index 99% rename from src/windy/x11pixels.nim rename to src/windy/platforms/linux/x11pixels.nim index 2100e149..5d909e64 100644 --- a/src/windy/x11pixels.nim +++ b/src/windy/platforms/linux/x11pixels.nim @@ -1,8 +1,8 @@ import std/[math, strformat], opengl, pixie, - ../windy, - ./platforms/linux/native/pixelpair + ../../../windy, + ./native/pixelpair export PixelRegion diff --git a/tests/bench_native_upload.nim b/tests/bench_native_upload.nim index cfb68a19..721f9982 100644 --- a/tests/bench_native_upload.nim +++ b/tests/bench_native_upload.nim @@ -1,7 +1,7 @@ import std/[monotimes, os, times], pixie, - windy/platforms/linux/native/drm + windy/platforms/linux/drm/display proc main() = doAssert paramCount() == 1, "Choose a DRM connector." diff --git a/tests/drm_gpu_patterns.nim b/tests/drm_gpu_patterns.nim index 22564c29..f86bfcbc 100644 --- a/tests/drm_gpu_patterns.nim +++ b/tests/drm_gpu_patterns.nim @@ -12,7 +12,7 @@ const SourceWidth* = 256 proc fit*(width, height, panelWidth, panelHeight: int): Viewport = - ## Center a positive integer enlargement inside a panel. + ## Center an image inside a panel using an integer scale. if min(width, height) <= 0 or min(panelWidth, panelHeight) <= 0: raise newException(ValueError, "Dimensions must be positive") let scale = min(panelWidth div width, panelHeight div height) diff --git a/tests/manual_drm_gpu.nim b/tests/manual_drm_gpu.nim index 9a36fb06..e636c2a6 100644 --- a/tests/manual_drm_gpu.nim +++ b/tests/manual_drm_gpu.nim @@ -3,7 +3,7 @@ import std/[algorithm, parseutils], ./drm_gpu_patterns -include windy/platforms/linux/native/drm +include windy/platforms/linux/drm/display type GbmHandle {.union.} = object diff --git a/tests/manual_drm_pixelpair.nim b/tests/manual_drm_pixelpair.nim index 07599579..fe9ebe87 100644 --- a/tests/manual_drm_pixelpair.nim +++ b/tests/manual_drm_pixelpair.nim @@ -1,4 +1,4 @@ -include windy/platforms/linux/native/drm +include windy/platforms/linux/drm/display proc pattern(width, height: int): Image = ## Generate asymmetric pixel colours without loading game data. diff --git a/tests/test_drm_pixelpair.nim b/tests/test_drm_pixelpair.nim index 58fa8dbe..9a559545 100644 --- a/tests/test_drm_pixelpair.nim +++ b/tests/test_drm_pixelpair.nim @@ -1,6 +1,7 @@ import std/unittest, - windy/[common, drmpixels], + windy/common, + windy/platforms/linux/drmpixels, windy/platforms/linux/native/pixelpair suite "GPU pixel pairs": diff --git a/tests/test_linux_drm.nim b/tests/test_linux_drm.nim index 1adc0505..aa894bf7 100644 --- a/tests/test_linux_drm.nim +++ b/tests/test_linux_drm.nim @@ -1,4 +1,4 @@ -include windy/platforms/linux/native/drm +include windy/platforms/linux/drm/display var signalFd: cint diff --git a/tests/test_native_input.nim b/tests/test_native_input.nim index cb3c67f6..d6963c4c 100644 --- a/tests/test_native_input.nim +++ b/tests/test_native_input.nim @@ -1,7 +1,8 @@ import pixie, windy/common, - windy/platforms/linux/native/[touch, pixels, framebuffer, drm] + windy/platforms/linux/drm/display, + windy/platforms/linux/native/[touch, pixels, framebuffer] block: let reader = TouchReader(width: 1024, height: 768, diff --git a/tests/test_x11_pixels.nim b/tests/test_x11_pixels.nim index 26d2afee..6d161a38 100644 --- a/tests/test_x11_pixels.nim +++ b/tests/test_x11_pixels.nim @@ -1,7 +1,7 @@ import std/[monotimes, os, osproc, streams, strutils, times, unittest], opengl, pixie, windy, - windy/x11pixels + windy/platforms/linux/x11pixels proc readBack(window: Window): Image = ## Read only synthetic pixels from the back buffer on the isolated test display. From 2e18c0375e1880229f4ad517f4a4c8f38f080b27 Mon Sep 17 00:00:00 2001 From: monofuel Date: Wed, 11 Nov 2026 15:13:26 -0500 Subject: [PATCH 19/21] Remove separate Linux display guides --- README.md | 2 +- docs/drm-pixel-pairs.md | 57 --------------------------------------- docs/linux-direct.md | 60 ----------------------------------------- docs/x11-pixels.md | 49 --------------------------------- 4 files changed, 1 insertion(+), 167 deletions(-) delete mode 100644 docs/drm-pixel-pairs.md delete mode 100644 docs/linux-direct.md delete mode 100644 docs/x11-pixels.md diff --git a/README.md b/README.md index 37809bc9..6ab5e45b 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ On Windows and X11 Linux, compile with `-d:useCpu` to create a plain window with OpenGL context. CPU renderers can update the client area by passing a Pixie image to `window.presentPixels(image)`. -Linux also has experimental [fbdev, DRM and evdev touch backends](docs/linux-direct.md) +Linux also has experimental fbdev, DRM and evdev touch backends for applications running without a window server. diff --git a/docs/drm-pixel-pairs.md b/docs/drm-pixel-pairs.md deleted file mode 100644 index 1fcfacc5..00000000 --- a/docs/drm-pixel-pairs.md +++ /dev/null @@ -1,57 +0,0 @@ -# DRM GPU pixels - -Use a DRM pixel pair when your app needs to scale images on the GPU and update -two displays together. Windy uploads one Pixie image, draws a region of it for -each display, and submits both displays in one atomic DRM update. - -The displays must be equally sized, unrotated, and on the same DRM device. This -requires hardware GPU rendering, GBM, EGL and OpenGL ES. Enable `useCpu` and the -`drm` backend in `config.nims` as shown in [the display setup](linux-direct.md). - -This example displays the top and bottom halves of an image on two connectors: - -```nim -import - pixie, windy, - windy/platforms/linux/drmpixels - -proc main() = - ## Display one image across two DRM outputs. - setLinuxBackend(DrmBackend) - let - first = newWindow("First", ivec2(0, 0), - linuxOptions = LinuxWindowOptions(device: "/dev/dri/card0", output: "DSI-1")) - second = newWindow("Second", ivec2(0, 0), - linuxOptions = LinuxWindowOptions(device: "/dev/dri/card0", output: "DSI-2")) - defer: first.close() - defer: second.close() - - let pair = newDrmPixelPair(first, second) - defer: pair.close() - let image = newImage(256, 384) - image.fill(color(0.2, 0.4, 0.8, 1)) - while not first.closeRequested and not second.closeRequested: - pollEvents() - pair.presentPixels(image, [ - PixelRegion(width: 256, height: 192), - PixelRegion(y: 192, width: 256, height: 192)]) - -main() -``` - -Choose the connector order for your app. Region coordinates start at the image's -top-left corner. Each region is scaled by a whole number and centered, with black -borders. An empty region leaves that display black. Images larger than a display -are shown at 1× and clipped. - -Create the pair before calling either window's CPU `presentPixels`. Keep it alive -across frames, use it on the window thread, and close it before closing the windows. -The windows still handle input while the pair handles rendering. - -`presentPixels` waits for both displays to finish their page flips before allowing -the old buffer to be reused. Both outputs receive the same frame update, although -the physical panels may refresh at different times. Unsupported hardware raises -an error; the pair does not automatically switch to CPU rendering. - -Run `nim r tests/test_drm_pixelpair.nim` for the headless tests. The hardware test -is `tests/manual_drm_pixelpair.nim`; it needs exclusive access to both displays. diff --git a/docs/linux-direct.md b/docs/linux-direct.md deleted file mode 100644 index 400e6b6a..00000000 --- a/docs/linux-direct.md +++ /dev/null @@ -1,60 +0,0 @@ -# Linux displays without X11 - -Windy can draw Pixie images directly to a Linux display through fbdev or DRM. -These backends are experimental and currently require a 64-bit Linux target. -Your app needs access to the display and input devices, with no other application -controlling the display. - -Put the backends you need in your application's `config.nims`: - -```nim -switch("define", "useCpu") -switch("define", "windyBackends=fbdev,drm") -``` - -You can include `x11` in the list for an app that also runs on a desktop. -Builds without `x11` do not need X11 libraries. DRM needs `libdrm.so.2` at runtime. - -Choose the backend before opening a window. For example, to fill a DRM display: - -```nim -import pixie, windy - -setLinuxBackend(DrmBackend) -let window = newWindow("Panel", ivec2(0, 0), vsync = true, - linuxOptions = LinuxWindowOptions( - device: "/dev/dri/card0", - output: "DSI-1", - touchDevice: "/dev/input/event1")) -let image = newImage(window.framebufferSize.x, window.framebufferSize.y) -image.fill(color(0.2, 0.4, 0.8, 1)) -window.presentPixels(image) -while not window.closeRequested: - pollEvents() -window.close() -``` - -Replace the device, connector and touch paths with those for your system. Omit -`touchDevice` if you do not need touch. Each window fills its display; the size -passed to `newWindow` is ignored. You can open separate windows on different DRM -connectors on the same device. - -For fbdev, choose `FbdevBackend` and open a window with `vsync = false` and -`LinuxWindowOptions(device: "/dev/fb0")`. Only one window can use that framebuffer. -Both backends accept `rotation` and `touchRotation` of 0, 90, 180 or 270 degrees. - -DRM's `vsync = true` draws into a second buffer and switches buffers between -refreshes to avoid tearing. Without vsync, and on fbdev, writes go straight to the -displayed buffer and can tear. Close all windows before switching backends. - -Touch events arrive through `window.onTouch`; `window.touches` holds the active -contacts. Positions are in window pixels. Both single-touch and multitouch evdev -devices are supported. Touch is not yet implemented for X11 or the other platforms. - -These backends do not provide keyboard, mouse or controller input. Desktop -operations such as resizing and clipboard access are also unavailable. Device -selection and controller input belong in the application. - -For GPU scaling and coordinated updates to two displays, see -[DRM GPU pixels](drm-pixel-pairs.md). Desktop GPU rendering is covered in -[X11 GPU pixels](x11-pixels.md). diff --git a/docs/x11-pixels.md b/docs/x11-pixels.md deleted file mode 100644 index ef173ae7..00000000 --- a/docs/x11-pixels.md +++ /dev/null @@ -1,49 +0,0 @@ -# X11 GPU pixels - -Use the X11 pixel presenter to scale a Pixie image on the GPU, with optional vsync. -It works in CPU pixel builds, so an app can choose between CPU and GPU rendering. -Add `switch("define", "useCpu")` to your application's `config.nims`. -The default Linux backend is X11. - -```nim -import - pixie, windy, - windy/platforms/linux/x11pixels - -proc main() = - ## Draw a Pixie image with the X11 GPU presenter. - let window = newWindow("GPU pixels", ivec2(800, 600), vsync = true, - linuxOptions = LinuxWindowOptions(gpuPixels: true)) - defer: window.close() - let presenter = newX11PixelPresenter(window) - defer: presenter.close() - let image = newImage(256, 224) - image.fill(color(0.2, 0.4, 0.8, 1)) - while not window.closeRequested: - pollEvents() - presenter.drawPixels(image, PixelRegion(width: image.width, height: image.height)) - presenter.swap() - -main() -``` - -The region uses coordinates from the image's top-left corner. Pixels are drawn -opaque, scaled by a whole number and centered with black borders. You can request -an exact size with `scale = 2`, or allow shrinking into a small window with -`fitDown = true`. - -Pass `changed = false` when the image has not changed to avoid uploading it again. -You must still draw and swap after a window resize or a change to the region. -To clear the window to black, pass a nil image and an empty `PixelRegion()`. - -Keep the presenter on the window thread and close it before closing the window. -It requires OpenGL 2.1. With `vsync = true`, startup fails if the GLX driver cannot -configure vsync; use `vsync = false` when you do not need it. - -For two windows, draw both before swapping either. X11 does not guarantee an -atomic update across windows. For coordinated updates on direct Linux displays, -see [DRM GPU pixels](drm-pixel-pairs.md). - -Run `nim r tests/test_x11_pixels.nim` for the headless rendering test. It requires -Xvfb and Mesa and checks the rendered pixels. Physical vsync still needs testing -on a real display. From 6eb3f45b4385d8065923e6965d39df71df914283 Mon Sep 17 00:00:00 2001 From: monofuel Date: Wed, 11 Nov 2026 15:24:35 -0500 Subject: [PATCH 20/21] Move fbdev backend directly under Linux --- src/windy/platforms/linux/direct.nim | 4 ++-- src/windy/platforms/linux/drm/display.nim | 3 ++- .../platforms/linux/{native/framebuffer.nim => fbdev.nim} | 2 +- src/windy/platforms/linux/native/state.nim | 4 ++-- src/windy/platforms/linux/native/touch.nim | 3 ++- tests/test_linux_framebuffer.nim | 2 +- tests/test_native_input.nim | 3 ++- 7 files changed, 12 insertions(+), 9 deletions(-) rename src/windy/platforms/linux/{native/framebuffer.nim => fbdev.nim} (99%) diff --git a/src/windy/platforms/linux/direct.nim b/src/windy/platforms/linux/direct.nim index 4e05d4de..7d10bb77 100644 --- a/src/windy/platforms/linux/direct.nim +++ b/src/windy/platforms/linux/direct.nim @@ -1,9 +1,9 @@ import pixie, ../../common, - ./backends, + ./[backends, fbdev], ./drm/display, - ./native/[state, framebuffer, touch] + ./native/[state, touch] when HasX11: import ./x11 diff --git a/src/windy/platforms/linux/drm/display.nim b/src/windy/platforms/linux/drm/display.nim index 124a1db9..b66f6212 100644 --- a/src/windy/platforms/linux/drm/display.nim +++ b/src/windy/platforms/linux/drm/display.nim @@ -1,7 +1,8 @@ import std/[dynlib, monotimes, os, posix, strformat, strutils, times], pixie, - ../native/[framebuffer, pixels, pixelpair] + ../fbdev, + ../native/[pixels, pixelpair] type ModeInfo = object diff --git a/src/windy/platforms/linux/native/framebuffer.nim b/src/windy/platforms/linux/fbdev.nim similarity index 99% rename from src/windy/platforms/linux/native/framebuffer.nim rename to src/windy/platforms/linux/fbdev.nim index 05512e10..b597883c 100644 --- a/src/windy/platforms/linux/native/framebuffer.nim +++ b/src/windy/platforms/linux/fbdev.nim @@ -1,7 +1,7 @@ import std/[os, posix, strformat], pixie, - ./pixels + ./native/pixels type Bitfield* {.importc: "struct fb_bitfield", header: "", bycopy.} = object diff --git a/src/windy/platforms/linux/native/state.nim b/src/windy/platforms/linux/native/state.nim index 143b7a89..34807ab6 100644 --- a/src/windy/platforms/linux/native/state.nim +++ b/src/windy/platforms/linux/native/state.nim @@ -1,8 +1,8 @@ import ../../../common, - ../backends, + ../[backends, fbdev], ../drm/display, - ./[framebuffer, touch] + ./touch type DirectWindowState* = ref object diff --git a/src/windy/platforms/linux/native/touch.nim b/src/windy/platforms/linux/native/touch.nim index 6a279974..c71e6d95 100644 --- a/src/windy/platforms/linux/native/touch.nim +++ b/src/windy/platforms/linux/native/touch.nim @@ -2,7 +2,8 @@ import std/[os, posix], pixie, ../../../common, - ./[framebuffer, pixels] + ../fbdev, + ./pixels type Event {.importc: "struct input_event", header: "", bycopy.} = object diff --git a/tests/test_linux_framebuffer.nim b/tests/test_linux_framebuffer.nim index bfab1262..466ad7b4 100644 --- a/tests/test_linux_framebuffer.nim +++ b/tests/test_linux_framebuffer.nim @@ -1,4 +1,4 @@ -include windy/platforms/linux/native/framebuffer +include windy/platforms/linux/fbdev proc checkPixels(bits: int, red, green, blue, alpha: Bitfield, rotation: int) = ## Compare packed scanout bytes with the generic converter, including padding. diff --git a/tests/test_native_input.nim b/tests/test_native_input.nim index d6963c4c..fb506cec 100644 --- a/tests/test_native_input.nim +++ b/tests/test_native_input.nim @@ -1,8 +1,9 @@ import pixie, windy/common, + windy/platforms/linux/fbdev, windy/platforms/linux/drm/display, - windy/platforms/linux/native/[touch, pixels, framebuffer] + windy/platforms/linux/native/[touch, pixels] block: let reader = TouchReader(width: 1024, height: 768, From b39b204309e164cdc166cca1d6c58efbc8884e58 Mon Sep 17 00:00:00 2001 From: monofuel Date: Wed, 11 Nov 2026 15:51:22 -0500 Subject: [PATCH 21/21] Keep Linux backend internals behind their entry modules --- .github/workflows/build.yml | 3 + src/windy/platforms/linux/direct.nim | 108 +++++++++++------- src/windy/platforms/linux/drm.nim | 2 + src/windy/platforms/linux/drm/display.nim | 11 +- src/windy/platforms/linux/drm/gpu.nim | 18 +++ src/windy/platforms/linux/drm/pixels.nim | 35 ++++++ src/windy/platforms/linux/drmpixels.nim | 33 ------ src/windy/platforms/linux/fbdev.nim | 12 +- .../platforms/linux/native/pixelpair.nim | 55 --------- src/windy/platforms/linux/native/pixels.nim | 37 ++++++ src/windy/platforms/linux/native/state.nim | 16 ++- src/windy/platforms/linux/native/touch.nim | 19 ++- src/windy/platforms/linux/platform.nim | 13 ++- src/windy/platforms/linux/x11.nim | 9 +- .../linux/{x11pixels.nim => x11/pixels.nim} | 6 - tests/bench_native_upload.nim | 2 +- tests/config.nims | 4 +- tests/manual_drm_gpu.nim | 2 +- tests/manual_drm_pixelpair.nim | 2 +- tests/test_drm_pixelpair.nim | 17 +-- tests/test_linux_drm.nim | 15 ++- tests/test_native_input.nim | 2 +- tests/test_x11_pixels.nim | 3 +- 23 files changed, 234 insertions(+), 190 deletions(-) create mode 100644 src/windy/platforms/linux/drm.nim create mode 100644 src/windy/platforms/linux/drm/pixels.nim delete mode 100644 src/windy/platforms/linux/drmpixels.nim delete mode 100644 src/windy/platforms/linux/native/pixelpair.nim rename src/windy/platforms/linux/{x11pixels.nim => x11/pixels.nim} (98%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e75fc90c..8e8394a3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,9 @@ jobs: # Run tests. - run: nim c tests/test.nim - run: nim c tests/manual_inputs.nim + - name: Check 32-bit fbdev without DRM + if: matrix.os == 'ubuntu-latest' + run: WINDY_TEST_BACKENDS=fbdev WINDY_TEST_TARGET=arm nim check tests/test_linux_display.nim - run: nim r tests/test_openurl.nim - run: nim r tests/test_macos.nim if: matrix.os == 'macos-latest' diff --git a/src/windy/platforms/linux/direct.nim b/src/windy/platforms/linux/direct.nim index 7d10bb77..eb488c09 100644 --- a/src/windy/platforms/linux/direct.nim +++ b/src/windy/platforms/linux/direct.nim @@ -1,10 +1,14 @@ import pixie, ../../common, - ./[backends, fbdev], - ./drm/display, + ./backends, ./native/[state, touch] +when HasFbdev: + import ./fbdev +when HasDrm: + import ./drm + when HasX11: import ./x11 type Window* = x11.Window @@ -21,11 +25,13 @@ else: var windows: seq[Window] proc close*(window: Window) = + ## Release input and display resources before removing the window. if window.direct == nil or window.direct.closed: return let data = window.direct - if data.drm != nil and data.drm.gpuOwned: - raise newException(WindyError, "Close the GPU pixel presenter before its windows") + when HasDrm: + if data.drm != nil and data.drm.gpuOwned: + raise newException(WindyError, "Close the GPU pixel presenter before its windows") data.closed = true try: if data.touch != nil: @@ -37,63 +43,79 @@ proc close*(window: Window) = data.touch.close() finally: try: - if data.fb != nil: data.fb.close() - if data.drm != nil and data.screen != nil: - data.drm.closeScreen(data.screen) + when HasFbdev: + if data.fb != nil: data.fb.close() + when HasDrm: + if data.drm != nil and data.screen != nil: + data.drm.closeScreen(data.screen) finally: - if data.drm != nil: - for index, screen in data.drm.screens: - if screen == data.screen: - data.drm.screens.delete(index) - break - if data.drm.screens.len == 0: - data.drm.close() + when HasDrm: + if data.drm != nil: + for index, screen in data.drm.screens: + if screen == data.screen: + data.drm.screens.delete(index) + break + if data.drm.screens.len == 0: + data.drm.close() for index, current in windows: if current == window: windows.delete(index) break +proc size*(window: Window): IVec2 = + ## Return the native dimensions of the selected display. + let data = window.direct + when HasFbdev: + if data.backend == FbdevBackend: + return ivec2(data.fb.width.int32, data.fb.height.int32) + when HasDrm: + return ivec2(data.screen.width.int32, data.screen.height.int32) + proc newDirectWindow*(title: string, backend: LinuxBackend, options: LinuxWindowOptions, vsync = false): Window = + ## Open one compiled direct display backend and its optional touch device. let path = if options.device.len > 0: options.device elif backend == FbdevBackend: "/dev/fb0" else: "/dev/dri/card0" result = Window(direct: DirectWindowState(title: title, device: path, backend: backend)) let data = result.direct try: - if backend == FbdevBackend: - for window in windows: - if window.direct.device == path: - raise newException(WindyError, "Framebuffer is already owned: " & path) - data.fb = openFramebuffer(path, options.rotation) - else: - if options.output.len == 0: - raise newException(WindyError, "A DRM connector must be selected") - for window in windows: - if window.direct.backend == DrmBackend and window.direct.device == path: - data.drm = window.direct.drm - break - if data.drm == nil: - data.drm = openDrm(path) - data.drm.openOutput(@[options.output], options.rotation, vsync) - data.screen = data.drm.screens[^1] + case backend + of FbdevBackend: + when HasFbdev: + for window in windows: + if window.direct.device == path: + raise newException(WindyError, "Framebuffer is already owned: " & path) + data.fb = openFramebuffer(path, options.rotation) + else: + raise newException(WindyError, "fbdev backend was not compiled") + of DrmBackend: + when HasDrm: + if options.output.len == 0: + raise newException(WindyError, "A DRM connector must be selected") + for window in windows: + if window.direct.backend == DrmBackend and window.direct.device == path: + data.drm = window.direct.drm + break + if data.drm == nil: + data.drm = openDrm(path) + data.drm.openOutput(@[options.output], options.rotation, vsync) + data.screen = data.drm.screens[^1] + else: + raise newException(WindyError, "DRM backend was not compiled") + of X11Backend: + raise newException(WindyError, "X11 is not a direct display backend") if options.touchDevice.len > 0: for window in windows: if window.direct.touch != nil and window.direct.touch.path == options.touchDevice: raise newException(WindyError, "Touchscreen is already assigned") - let - width = if data.fb != nil: data.fb.width else: data.screen.width - height = if data.fb != nil: data.fb.height else: data.screen.height - data.touch = openTouch(options.touchDevice, width, height, options.touchRotation) + let dimensions = result.size + data.touch = openTouch(options.touchDevice, dimensions.x.int, dimensions.y.int, + options.touchRotation) windows.add result except: result.close() raise -proc size*(window: Window): IVec2 = - let data = window.direct - if data.fb != nil: ivec2(data.fb.width.int32, data.fb.height.int32) - else: ivec2(data.screen.width.int32, data.screen.height.int32) - proc framebufferSize*(window: Window): IVec2 = window.size proc closed*(window: Window): bool = window.direct.closed proc closeRequested*(window: Window): bool = window.direct.closeRequested @@ -114,9 +136,11 @@ proc presentPixels*(window: Window, image: Image) = if window.closed: raise newException(WindyError, "Cannot present to a closed window") let data = window.direct - if data.fb != nil: - data.fb.present(image) - else: + when HasFbdev: + if data.fb != nil: + data.fb.present(image) + return + when HasDrm: for index, screen in data.drm.screens: if screen == data.screen: data.drm.present(index, image) diff --git a/src/windy/platforms/linux/drm.nim b/src/windy/platforms/linux/drm.nim new file mode 100644 index 00000000..413abae9 --- /dev/null +++ b/src/windy/platforms/linux/drm.nim @@ -0,0 +1,2 @@ +include drm/display +include drm/pixels diff --git a/src/windy/platforms/linux/drm/display.nim b/src/windy/platforms/linux/drm/display.nim index b66f6212..aeb01871 100644 --- a/src/windy/platforms/linux/drm/display.nim +++ b/src/windy/platforms/linux/drm/display.nim @@ -1,8 +1,7 @@ import std/[dynlib, monotimes, os, posix, strformat, strutils, times], pixie, - ../fbdev, - ../native/[pixels, pixelpair] + ../native/pixels type ModeInfo = object @@ -284,7 +283,7 @@ proc closeScreen*(display: DrmDisplay, screen: DrmScreen) = else: if framebuffer.handle != 0: var handle = GemClose(handle: framebuffer.handle) - if deviceIoctl(display.fd, CloseGem, addr handle) < 0: + if posix.ioctl(display.fd, CloseGem, addr handle) < 0: errors.add "Closing original GEM handle for " & screen.name display.api.freeFb(framebuffer) let @@ -306,7 +305,7 @@ proc closeScreen*(display: DrmDisplay, screen: DrmScreen) = errors.add "Removing framebuffer for " & screen.name scanout.framebuffer = 0 if scanout.buffer.handle != 0: - if deviceIoctl(display.fd, DestroyDumb, addr scanout.buffer.handle) < 0: + if posix.ioctl(display.fd, DestroyDumb, addr scanout.buffer.handle) < 0: errors.add "Destroying buffer for " & screen.name scanout.buffer.handle = 0 @@ -450,7 +449,7 @@ proc openOutput*(result: DrmDisplay, outputs: seq[string], rotation: int, vsync for scanout in screen.buffers.mitems: scanout.buffer = DumbBuffer(width: uint32(screen.mode.hdisplay), height: uint32(screen.mode.vdisplay), bpp: 32) - if deviceIoctl(result.fd, CreateDumb, addr scanout.buffer) < 0: + if posix.ioctl(result.fd, CreateDumb, addr scanout.buffer) < 0: raiseOSError(osLastError(), "Allocating DRM scanout buffer") if scanout.buffer.pitch < scanout.buffer.width * 4 or scanout.buffer.size < uint64(scanout.buffer.pitch) * scanout.buffer.height or @@ -468,7 +467,7 @@ proc openOutput*(result: DrmDisplay, outputs: seq[string], rotation: int, vsync scanout.buffer.pitch, scanout.buffer.handle, addr scanout.framebuffer) < 0: raiseOSError(osLastError(), "Registering DRM framebuffer") var mapping = MapBuffer(handle: scanout.buffer.handle) - if deviceIoctl(result.fd, MapDumb, addr mapping) < 0: + if posix.ioctl(result.fd, MapDumb, addr mapping) < 0: raiseOSError(osLastError(), "Mapping DRM buffer") scanout.memory = mmap(nil, int(scanout.buffer.size), PROT_READ or PROT_WRITE, MAP_SHARED, result.fd, Off(mapping.offset)) diff --git a/src/windy/platforms/linux/drm/gpu.nim b/src/windy/platforms/linux/drm/gpu.nim index 09e9d047..19061d75 100644 --- a/src/windy/platforms/linux/drm/gpu.nim +++ b/src/windy/platforms/linux/drm/gpu.nim @@ -1,5 +1,9 @@ # Included by the DRM backend to share its private native bindings. type + GpuFlipPair = object + crtcs: array[2, uint32] + completed: set[range[0..1]] + timestamps: array[2, uint64] GpuGbmHandle {.union.} = object pointerValue: pointer u32: uint32 @@ -359,6 +363,20 @@ proc gpuCommit(probe: GpuDisplay, values: seq[GpuAtomicValue], flags: uint32, da gpuChecked(probe.atomic.add(request, item.id, item.gpuProperty, item.value) >= 0, "Adding atomic property") gpuChecked(probe.atomic.gpuCommit(probe.display.fd, request, flags, data) == 0, "Committing paired display update") +proc complete(pair: var GpuFlipPair, crtc: uint32, timestamp: uint64) = + ## Record one event per controller before either old scanout can be retired. + for index in 0..1: + if pair.crtcs[index] == crtc: + if index in pair.completed: raise newException(ValueError, "Duplicate flip completion") + pair.completed.incl index + pair.timestamps[index] = timestamp + return + raise newException(ValueError, "Completion from an unexpected CRTC") + +proc ready(pair: GpuFlipPair): bool = + ## Return whether both controllers have completed the generation. + pair.completed == {0, 1} + proc gpuFlip2(fd: cint, sequence, seconds, microseconds, crtc: cuint, data: pointer) {.cdecl.} = ## Handle both events without allowing a Nim exception across a C callback. let probe = cast[GpuDisplay](data) diff --git a/src/windy/platforms/linux/drm/pixels.nim b/src/windy/platforms/linux/drm/pixels.nim new file mode 100644 index 00000000..341bee91 --- /dev/null +++ b/src/windy/platforms/linux/drm/pixels.nim @@ -0,0 +1,35 @@ +import + ../../../common, + ../backends + +export PixelRegion + +type DrmPixelPair* = ref object + display: GpuDisplay + +proc newDrmPixelPair*[T](first, second: T): DrmPixelPair = + ## Claim two equal native DRM windows for GPU pixels; close them after this pair. + when T is typeof(nil): + raise newException(WindyError, "GPU pixel pairs require native DRM windows") + else: + if first == nil or second == nil: + raise newException(WindyError, "GPU pixel pairs require native DRM windows") + let + a = first.direct + b = second.direct + if a == nil or b == nil: + raise newException(WindyError, "GPU pixel pairs require native DRM windows") + if a.closed or b.closed or a.backend != DrmBackend or b.backend != DrmBackend or + a.drm == nil or b.drm == nil or a.screen == nil or b.screen == nil or a.drm != b.drm: + raise newException(WindyError, "GPU pixel pairs require open windows on one DRM device") + result = DrmPixelPair(display: newGpuDisplay(a.drm, [a.screen, b.screen])) + +proc presentPixels*(pair: DrmPixelPair, image: Image, regions: array[2, PixelRegion]) = + ## Center integer-scaled source crops, using black for an empty crop or nil image. + if pair == nil: raise newException(WindyError, "GPU pixel pair is nil") + pair.display.presentGpuPixels(image, regions) + +proc close*(pair: DrmPixelPair) = + ## Restore both outputs and release GPU resources before closing the windows. + if pair == nil: return + pair.display.close() diff --git a/src/windy/platforms/linux/drmpixels.nim b/src/windy/platforms/linux/drmpixels.nim deleted file mode 100644 index b4258a1a..00000000 --- a/src/windy/platforms/linux/drmpixels.nim +++ /dev/null @@ -1,33 +0,0 @@ -import - pixie, - ../../common, - ./[backends, direct], - ./drm/display, - ./native/pixelpair - -export PixelRegion - -type DrmPixelPair* = ref object - display: GpuDisplay - -proc newDrmPixelPair*(first, second: Window): DrmPixelPair = - ## Claim two equal native DRM windows for GPU pixels; close them after this pair. - if first == nil or second == nil or first.direct == nil or second.direct == nil: - raise newException(WindyError, "GPU pixel pairs require native DRM windows") - let - a = first.direct - b = second.direct - if a.closed or b.closed or a.backend != DrmBackend or b.backend != DrmBackend or - a.drm == nil or b.drm == nil or a.screen == nil or b.screen == nil or a.drm != b.drm: - raise newException(WindyError, "GPU pixel pairs require open windows on one DRM device") - DrmPixelPair(display: newGpuDisplay(a.drm, [a.screen, b.screen])) - -proc presentPixels*(pair: DrmPixelPair, image: Image, regions: array[2, PixelRegion]) = - ## Center integer-scaled source crops, using black for an empty crop or nil image. - if pair == nil: raise newException(WindyError, "GPU pixel pair is nil") - pair.display.presentGpuPixels(image, regions) - -proc close*(pair: DrmPixelPair) = - ## Restore both outputs and release GPU resources before closing the windows. - if pair == nil: return - pair.display.close() diff --git a/src/windy/platforms/linux/fbdev.nim b/src/windy/platforms/linux/fbdev.nim index b597883c..5382e66f 100644 --- a/src/windy/platforms/linux/fbdev.nim +++ b/src/windy/platforms/linux/fbdev.nim @@ -43,8 +43,6 @@ const Blank = 0x4611.culong ActivateForce = 128'u32 -proc deviceIoctl*(fd: cint, request: culong, argument: pointer): cint {.importc: "ioctl", header: "".} - proc packPixel*(color: uint32, red, green, blue, alpha: Bitfield): uint32 = for (value, channel) in [(color shr 16 and 255, red), (color shr 8 and 255, green), (color and 255, blue), (255'u32, alpha)]: @@ -55,7 +53,7 @@ proc packPixel*(color: uint32, red, green, blue, alpha: Bitfield): uint32 = result = result or ((value * maximum + 127) div 255 shl channel.offset) proc checkedIoctl(fd: cint, request: culong, argument: pointer) = - if deviceIoctl(fd, request, argument) < 0: + if posix.ioctl(fd, request, argument) < 0: raiseOSError(osLastError()) proc openFramebuffer*(path: string, rotation = 0): Framebuffer = @@ -140,7 +138,7 @@ proc present*(fb: Framebuffer, image: Image) = if not fb.activated: fb.variable.xoffset = 0 fb.variable.yoffset = 0 - if deviceIoctl(fb.fd, PanDisplay, addr fb.variable) < 0: + if posix.ioctl(fb.fd, PanDisplay, addr fb.variable) < 0: fb.variable.activate = ActivateForce checkedIoctl(fb.fd, PutVariable, addr fb.variable) checkedIoctl(fb.fd, Blank, nil) @@ -148,13 +146,13 @@ proc present*(fb: Framebuffer, image: Image) = proc close*(fb: Framebuffer) = if fb.memory != nil: - var restoreResult = 0.cint + var restoreResult = 0 if fb.touched: copyMem(fb.memory, addr fb.saved[0], fb.length) if fb.activated: - restoreResult = deviceIoctl(fb.fd, PanDisplay, addr fb.original) + restoreResult = posix.ioctl(fb.fd, PanDisplay, addr fb.original) if restoreResult < 0: - restoreResult = deviceIoctl(fb.fd, PutVariable, addr fb.original) + restoreResult = posix.ioctl(fb.fd, PutVariable, addr fb.original) let unmapResult = munmap(fb.memory, fb.length) fb.memory = nil let closeResult = posix.close(fb.fd) diff --git a/src/windy/platforms/linux/native/pixelpair.nim b/src/windy/platforms/linux/native/pixelpair.nim deleted file mode 100644 index 9b559a24..00000000 --- a/src/windy/platforms/linux/native/pixelpair.nim +++ /dev/null @@ -1,55 +0,0 @@ -type - PixelRegion* = object - x*, y*, width*, height*: int - GpuFlipPair* = object - crtcs*: array[2, uint32] - completed*: set[range[0..1]] - timestamps*: array[2, uint64] - -proc validateRegions*(width, height: int, regions: array[2, PixelRegion]) = - ## Validate source crops; an empty region leaves its output black. - for region in regions: - if region == PixelRegion(): continue - if width <= 0 or height <= 0 or region.x < 0 or region.y < 0 or - region.width <= 0 or region.height <= 0 or region.x > width or - region.y > height or region.width > width - region.x or - region.height > height - region.y: - raise newException(ValueError, "Invalid pixel source region") - -proc pixelViewport*(region: PixelRegion, width, height: int): PixelRegion = - ## Center whole-number scaling, clipping at native size when necessary. - if min(region.width, region.height) <= 0 or min(width, height) <= 0: - raise newException(ValueError, "Invalid pixel viewport dimensions") - let scale = max(1, min(width div region.width, height div region.height)) - result.width = region.width * scale - result.height = region.height * scale - result.x = (width - result.width) div 2 - result.y = (height - result.height) div 2 - -proc pixelVertices*(width, height: int, regions: array[2, PixelRegion]): array[48, cfloat] = - ## Address top-left source crops with OpenGL's bottom-left vertex convention. - validateRegions(width, height, regions) - if width <= 0 or height <= 0: return - for index, region in regions: - let - left = cfloat(region.x) / cfloat(width) - right = cfloat(region.x + region.width) / cfloat(width) - top = cfloat(region.y) / cfloat(height) - bottom = cfloat(region.y + region.height) / cfloat(height) - vertices = [-1.cfloat, -1, left, bottom, 1, -1, right, bottom, - -1, 1, left, top, -1, 1, left, top, 1, -1, right, bottom, 1, 1, right, top] - for offset, value in vertices: result[index * vertices.len + offset] = value - -proc complete*(pair: var GpuFlipPair, crtc: uint32, timestamp: uint64) = - ## Record one event per controller before either old scanout can be retired. - for index in 0..1: - if pair.crtcs[index] == crtc: - if index in pair.completed: raise newException(ValueError, "Duplicate flip completion") - pair.completed.incl index - pair.timestamps[index] = timestamp - return - raise newException(ValueError, "Completion from an unexpected CRTC") - -proc ready*(pair: GpuFlipPair): bool = - ## Return whether both controllers have completed the generation. - pair.completed == {0, 1} diff --git a/src/windy/platforms/linux/native/pixels.nim b/src/windy/platforms/linux/native/pixels.nim index 85bd384d..27c61176 100644 --- a/src/windy/platforms/linux/native/pixels.nim +++ b/src/windy/platforms/linux/native/pixels.nim @@ -1,5 +1,8 @@ import pixie +type PixelRegion* = object + x*, y*, width*, height*: int + proc rgb*(pixel: ColorRGBX): uint32 {.inline.} = uint32(pixel.r) shl 16 or uint32(pixel.g) shl 8 or uint32(pixel.b) @@ -10,3 +13,37 @@ proc rotatedPoint*(x, y, width, height, rotation: int): tuple[x, y: int] {.inlin of 180: (width - 1 - x, height - 1 - y) of 270: (y, width - 1 - x) else: raise newException(ValueError, "Rotation must be 0, 90, 180 or 270") + +proc validateRegions*(width, height: int, regions: array[2, PixelRegion]) = + ## Validate source crops; an empty region leaves its output black. + for region in regions: + if region == PixelRegion(): continue + if width <= 0 or height <= 0 or region.x < 0 or region.y < 0 or + region.width <= 0 or region.height <= 0 or region.x > width or + region.y > height or region.width > width - region.x or + region.height > height - region.y: + raise newException(ValueError, "Invalid pixel source region") + +proc pixelViewport*(region: PixelRegion, width, height: int): PixelRegion = + ## Center whole-number scaling, clipping at native size when necessary. + if min(region.width, region.height) <= 0 or min(width, height) <= 0: + raise newException(ValueError, "Invalid pixel viewport dimensions") + let scale = max(1, min(width div region.width, height div region.height)) + result.width = region.width * scale + result.height = region.height * scale + result.x = (width - result.width) div 2 + result.y = (height - result.height) div 2 + +proc pixelVertices*(width, height: int, regions: array[2, PixelRegion]): array[48, cfloat] = + ## Address top-left source crops with OpenGL's bottom-left vertex convention. + validateRegions(width, height, regions) + if width <= 0 or height <= 0: return + for index, region in regions: + let + left = cfloat(region.x) / cfloat(width) + right = cfloat(region.x + region.width) / cfloat(width) + top = cfloat(region.y) / cfloat(height) + bottom = cfloat(region.y + region.height) / cfloat(height) + vertices = [-1.cfloat, -1, left, bottom, 1, -1, right, bottom, + -1, 1, left, top, -1, 1, left, top, 1, -1, right, bottom, 1, 1, right, top] + for offset, value in vertices: result[index * vertices.len + offset] = value diff --git a/src/windy/platforms/linux/native/state.nim b/src/windy/platforms/linux/native/state.nim index 34807ab6..8ab3b9f9 100644 --- a/src/windy/platforms/linux/native/state.nim +++ b/src/windy/platforms/linux/native/state.nim @@ -1,14 +1,20 @@ import ../../../common, - ../[backends, fbdev], - ../drm/display, + ../backends, ./touch +when HasFbdev: + import ../fbdev +when HasDrm: + import ../drm + type DirectWindowState* = ref object - fb*: Framebuffer - drm*: DrmDisplay - screen*: DrmScreen + when HasFbdev: + fb*: Framebuffer + when HasDrm: + drm*: DrmDisplay + screen*: DrmScreen touch*: TouchReader device*, title*: string backend*: LinuxBackend diff --git a/src/windy/platforms/linux/native/touch.nim b/src/windy/platforms/linux/native/touch.nim index c71e6d95..b6b3f145 100644 --- a/src/windy/platforms/linux/native/touch.nim +++ b/src/windy/platforms/linux/native/touch.nim @@ -2,7 +2,6 @@ import std/[os, posix], pixie, ../../../common, - ../fbdev, ./pixels type @@ -66,13 +65,13 @@ proc cancel*(reader: TouchReader): seq[TouchEvent] = proc resync(reader: TouchReader) = if reader.multitouch: var axis: Axis - if deviceIoctl(reader.fd, getAxis(47), addr axis) < 0: + if posix.ioctl(reader.fd, getAxis(47), addr axis) < 0: raiseOSError(osLastError(), reader.path) reader.slot = int(axis.value) - reader.slotMinimum for code in [57'i32, 53, 54]: var values = newSeq[int32](reader.pending.len + 1) values[0] = code - if deviceIoctl(reader.fd, getSlots(cint(values.len * sizeof(int32))), addr values[0]) < 0: + if posix.ioctl(reader.fd, getSlots(cint(values.len * sizeof(int32))), addr values[0]) < 0: raiseOSError(osLastError(), reader.path) for index in 0 ..< reader.pending.len: case code @@ -84,9 +83,9 @@ proc resync(reader: TouchReader) = var keys: array[96, byte] x, y: Axis - if deviceIoctl(reader.fd, getKeys(cint(keys.len)), addr keys[0]) < 0 or - deviceIoctl(reader.fd, getAxis(0), addr x) < 0 or - deviceIoctl(reader.fd, getAxis(1), addr y) < 0: + if posix.ioctl(reader.fd, getKeys(cint(keys.len)), addr keys[0]) < 0 or + posix.ioctl(reader.fd, getAxis(0), addr x) < 0 or + posix.ioctl(reader.fd, getAxis(1), addr y) < 0: raiseOSError(osLastError(), reader.path) reader.pending[0] = Slot(id: (if (keys[330 div 8] and (1'u8 shl (330 mod 8))) != 0: 0'i32 else: -1'i32), x: x.value, y: y.value) @@ -108,11 +107,11 @@ proc openTouch*(path: string, width, height, rotation: int): TouchReader = var bits: array[8, byte] slots, x, y: Axis - if deviceIoctl(result.fd, getBits(3, cint(bits.len)), addr bits[0]) < 0: + if posix.ioctl(result.fd, getBits(3, cint(bits.len)), addr bits[0]) < 0: raiseOSError(osLastError(), path) result.multitouch = (bits[47 div 8] and (1'u8 shl (47 mod 8))) != 0 if result.multitouch: - if deviceIoctl(result.fd, getAxis(47), addr slots) < 0: + if posix.ioctl(result.fd, getAxis(47), addr slots) < 0: raiseOSError(osLastError(), path) result.slotMinimum = int(slots.minimum) let @@ -121,8 +120,8 @@ proc openTouch*(path: string, width, height, rotation: int): TouchReader = yCode = if result.multitouch: 54 else: 1 if count notin 1 .. 32: raise newException(WindyError, "Unsupported touch slot count") - if deviceIoctl(result.fd, getAxis(cint(xCode)), addr x) < 0 or - deviceIoctl(result.fd, getAxis(cint(yCode)), addr y) < 0: + if posix.ioctl(result.fd, getAxis(cint(xCode)), addr x) < 0 or + posix.ioctl(result.fd, getAxis(cint(yCode)), addr y) < 0: raiseOSError(osLastError(), path) if x.maximum <= x.minimum or y.maximum <= y.minimum: raise newException(WindyError, "Invalid touchscreen coordinate ranges") diff --git a/src/windy/platforms/linux/platform.nim b/src/windy/platforms/linux/platform.nim index 34de0a61..9ae5f620 100644 --- a/src/windy/platforms/linux/platform.nim +++ b/src/windy/platforms/linux/platform.nim @@ -1,19 +1,26 @@ -import ./backends +import ../../common, ./backends export backends when not HasDirect: - include x11 + import ./x11 + export x11 proc setLinuxBackend*(backend: LinuxBackend) = if backend != X11Backend: raise newException(WindyError, "Backend was not compiled into Windy") else: - import pixie, ../../common, ./direct + import pixie, ./direct import ../../http export http when HasX11: import ./x11 export x11.quitRequested, x11.onQuitRequest, x11.multiClickInterval, x11.multiClickRadius + export x11.X11PixelPresenter, x11.newX11PixelPresenter, x11.drawPixels, + x11.swap, x11.PixelRegion + + proc close*(presenter: x11.X11PixelPresenter) = + ## Release the X11 pixel presenter's GPU resources before its window. + x11.close(presenter) else: var quitRequested*: bool diff --git a/src/windy/platforms/linux/x11.nim b/src/windy/platforms/linux/x11.nim index 096a4b51..6c3e469d 100644 --- a/src/windy/platforms/linux/x11.nim +++ b/src/windy/platforms/linux/x11.nim @@ -1,8 +1,9 @@ import - std/[os, osproc, sequtils, sets, strformat, strutils, times, unicode, uri, pathnorm], + std/[math, os, osproc, sequtils, sets, strformat, strutils, times, unicode, uri, pathnorm], ../../[common, internal], - vmath, pixie, - x11/[glx, keysym, x, xevent, xlib, xcursor] + opengl, vmath, pixie, + x11/[glx, keysym, x, xevent, xlib, xcursor], + ./native/pixels import ../../http export http @@ -1608,3 +1609,5 @@ when defined(useCpu): proc hasWindows*(): bool = for window in windows: if not window.closed: return true + +include x11/pixels diff --git a/src/windy/platforms/linux/x11pixels.nim b/src/windy/platforms/linux/x11/pixels.nim similarity index 98% rename from src/windy/platforms/linux/x11pixels.nim rename to src/windy/platforms/linux/x11/pixels.nim index 5d909e64..aa012328 100644 --- a/src/windy/platforms/linux/x11pixels.nim +++ b/src/windy/platforms/linux/x11/pixels.nim @@ -1,9 +1,3 @@ -import - std/[math, strformat], - opengl, pixie, - ../../../windy, - ./native/pixelpair - export PixelRegion type diff --git a/tests/bench_native_upload.nim b/tests/bench_native_upload.nim index 721f9982..933801ff 100644 --- a/tests/bench_native_upload.nim +++ b/tests/bench_native_upload.nim @@ -1,7 +1,7 @@ import std/[monotimes, os, times], pixie, - windy/platforms/linux/drm/display + windy/platforms/linux/drm proc main() = doAssert paramCount() == 1, "Choose a DRM connector." diff --git a/tests/config.nims b/tests/config.nims index 4571b14d..5050cf6c 100644 --- a/tests/config.nims +++ b/tests/config.nims @@ -19,7 +19,9 @@ if testBackends.len > 0: switch("define", "useCpu") switch("define", "windyBackends=" & testBackends) -if getEnv("WINDY_TEST_TARGET") == "arm64": +if getEnv("WINDY_TEST_TARGET") == "arm": + switch("cpu", "arm") +elif getEnv("WINDY_TEST_TARGET") == "arm64": switch("cpu", "arm64") switch("gcc.exe", getEnv("MONO_RETRO_CC", "aarch64-unknown-linux-gnu-gcc")) switch("gcc.linkerexe", getEnv("MONO_RETRO_CC", "aarch64-unknown-linux-gnu-gcc")) diff --git a/tests/manual_drm_gpu.nim b/tests/manual_drm_gpu.nim index e636c2a6..bf83a310 100644 --- a/tests/manual_drm_gpu.nim +++ b/tests/manual_drm_gpu.nim @@ -3,7 +3,7 @@ import std/[algorithm, parseutils], ./drm_gpu_patterns -include windy/platforms/linux/drm/display +include windy/platforms/linux/drm type GbmHandle {.union.} = object diff --git a/tests/manual_drm_pixelpair.nim b/tests/manual_drm_pixelpair.nim index fe9ebe87..74c09df4 100644 --- a/tests/manual_drm_pixelpair.nim +++ b/tests/manual_drm_pixelpair.nim @@ -1,4 +1,4 @@ -include windy/platforms/linux/drm/display +include windy/platforms/linux/drm proc pattern(width, height: int): Image = ## Generate asymmetric pixel colours without loading game data. diff --git a/tests/test_drm_pixelpair.nim b/tests/test_drm_pixelpair.nim index 9a559545..c711b572 100644 --- a/tests/test_drm_pixelpair.nim +++ b/tests/test_drm_pixelpair.nim @@ -1,8 +1,8 @@ import std/unittest, - windy/common, - windy/platforms/linux/drmpixels, - windy/platforms/linux/native/pixelpair + windy, + windy/platforms/linux/drm, + windy/platforms/linux/native/pixels suite "GPU pixel pairs": test "DS and EarthBound preserve integer pixels and borders": @@ -28,15 +28,8 @@ suite "GPU pixel pairs": check vertices[11] == 0 check vertices[27] == 1 check vertices[35] == 0.5 - test "both controllers must complete in either order": - for reverse in [false, true]: - var pair = GpuFlipPair(crtcs: [69'u32, 70]) - pair.complete(if reverse: 70 else: 69, 100) - check not pair.ready - pair.complete(if reverse: 69 else: 70, 106) - check pair.ready - expect ValueError: pair.complete(69, 107) - expect ValueError: pair.complete(71, 107) test "invalid window ownership fails without opening GPU libraries": expect WindyError: discard newDrmPixelPair(nil, nil) + expect WindyError: discard newDrmPixelPair(Window(nil), Window(nil)) + expect WindyError: discard newDrmPixelPair(Window(), Window()) close(DrmPixelPair(nil)) diff --git a/tests/test_linux_drm.nim b/tests/test_linux_drm.nim index aa894bf7..7cbeeb26 100644 --- a/tests/test_linux_drm.nim +++ b/tests/test_linux_drm.nim @@ -1,4 +1,17 @@ -include windy/platforms/linux/drm/display +import std/unittest + +include windy/platforms/linux/drm + +suite "DRM paired flips": + test "both controllers must complete in either order": + for reverse in [false, true]: + var pair = GpuFlipPair(crtcs: [69'u32, 70]) + pair.complete(if reverse: 70 else: 69, 100) + check not pair.ready + pair.complete(if reverse: 69 else: 70, 106) + check pair.ready + expect ValueError: pair.complete(69, 107) + expect ValueError: pair.complete(71, 107) var signalFd: cint diff --git a/tests/test_native_input.nim b/tests/test_native_input.nim index fb506cec..02f337d9 100644 --- a/tests/test_native_input.nim +++ b/tests/test_native_input.nim @@ -2,7 +2,7 @@ import pixie, windy/common, windy/platforms/linux/fbdev, - windy/platforms/linux/drm/display, + windy/platforms/linux/drm, windy/platforms/linux/native/[touch, pixels] block: diff --git a/tests/test_x11_pixels.nim b/tests/test_x11_pixels.nim index 6d161a38..5c486c7d 100644 --- a/tests/test_x11_pixels.nim +++ b/tests/test_x11_pixels.nim @@ -1,7 +1,6 @@ import std/[monotimes, os, osproc, streams, strutils, times, unittest], - opengl, pixie, windy, - windy/platforms/linux/x11pixels + opengl, pixie, windy proc readBack(window: Window): Image = ## Read only synthetic pixels from the back buffer on the isolated test display.