8 Commits

Author SHA1 Message Date
matthias a2fb95e57b Add Windows cross-compilation via MinGW, and fix Docker build permission
build / build (push) Successful in 3m5s
issues on vboxsf-mounted checkouts

New `make dist-win`/`package-win` targets (folded into `dist`/`package`
alongside the renamed `dist-linux`/`package-linux`) cross-compile
systemshock.exe via MinGW, using prebuilt SDL2/SDL2_mixer/GLEW/
fluidsynth-lite baked into the build-image - no Windows machine or Wine
needed to build it, confirmed working and playable on a real Windows
machine. dist-win/ ships DLLs flat alongside the exe plus a new
res/run.bat launcher, packaged into a .zip the same way dist/ becomes a
.tar.gz.

Also fixes three build-image bugs hit while testing on a VirtualBox
vboxsf-mounted checkout: build-engine.sh/docker-entrypoint.sh losing
their execute bit (chmod +x on restrictive source perms), the container
user missing access to /workspace's supplementary vboxsf group, and
cp -a failing on symlink/hard-link creation (vboxsf doesn't support
either) - now falls back to dereferencing copies when detected. Also
adds Docker/zip/etc. prerequisites to the README for both the desktop
and Quest builds.
2026-08-16 06:27:31 +02:00
ml 581af95e7b Drive the engine's mouse cursor from the VR aim ray on the game quad
build / build (push) Successful in 1m44s
The game quad's laser beam was purely visual - aiming at the game's own
UI (inventory, menus, dialogs) had no way to interact with it. Add a
real mouse pointer: absolute cursor position plus a single left click,
mirroring a plain point-and-click mouse (no mouselook, no right-click,
no drag).

New xr_mouse.c/h bridges the aim-ray hit-test to the vendored engine's
own public mouse API - mouse_put_xy() for position, a synthetic
SDL_MOUSEBUTTONDOWN/UP (SDL_BUTTON_LEFT) via SDL_PushEvent for clicks,
the same technique nativeSendPrintableChar() already uses for
synthetic keyboard input. No engine patch needed. Deliberately bypasses
SDLActivity.onNativeMouse()/SDL_MOUSEMOTION/SetMouseXY(), whose
physical-window-size scaling is unreliable in this headless immersive
build.

xr_input_try_game_quad() now reports the hit u,v; a new
g_game_touch_active[] tracks a held click on the game quad so it
survives the ray straying onto the keyboard/menu before release,
gating those overlays' claims the same way pending overlay touches
already do.
2026-08-14 08:36:09 +02:00
ml 824b4a2506 Give the game quad the same laser-beam pointer as the overlays
Aiming looked and behaved differently depending on whether the
keyboard/menu was open: xr_input_try_overlay() claimed a hand's ray for
its overlay whenever the overlay was merely visible, regardless of
whether the ray was actually pointed at it - so with the keyboard open,
aiming at the main game screen still showed a beam terminating at the
keyboard's fixed depth, while closing the keyboard swapped in the old
flat cross-shaped reticle at whatever the ray was really pointing at.

Only claim an overlay when the ray is actually relevant this frame - a
real hit, or a touch/drag begun on a previous frame still pending
release - so a visible-but-unaimed-at overlay now falls through to the
next-priority target. Give the game quad its own hit-test
(xr_input_try_game_quad()) and the same billboarded laser-beam
treatment as the overlays instead of the old flat reticle, so aiming
looks and behaves identically everywhere - overlay open or not. The
old reticle GL program/shaders are now fully dead and removed. No
click/touch dispatch is added to the game quad itself; that's future
work.
2026-08-14 07:59:09 +02:00
ml 920842a465 Show a laser-beam pointer while aiming at the menu/keyboard
build / build (push) Successful in 1m40s
The flat cross-shaped reticle used while aiming at the game quad is
drawn directly into the game quad's own texture, so it only ever made
sense there - while the menu launcher or keyboard was open, there was
no visual feedback at all until the ray was precisely on target,
making both panels hard to aim at.

Add a second, thin XrCompositionLayerQuad per hand: a billboarded
"laser beam" ribbon from the controller to wherever that hand's ray
currently crosses the plane of whichever overlay (keyboard, then menu)
claims it that frame, oriented via a view-space head pose so it reads
as a line from the viewer's eye regardless of angle, colored per hand
(cyan left, amber right) and clamped to 2m so grazing angles don't
produce an absurdly long beam. Built from data xr_input.c's existing
per-hand hit-testing already computes; the game-quad reticle itself is
untouched.
2026-08-14 07:30:13 +02:00
ml 3be310ade6 Split the keyboard onto its own translucent OpenXR quad
build / build (push) Successful in 1m52s
Expand the on-screen keyboard to a full US layout (letters, digits,
punctuation, Shift layer, Tab, arrows, F1-F12, one-shot Ctrl/Alt
modifiers), make it a persistent, movable, closeable panel via a
title-bar drag handle, and lower its opacity.

Previously the keyboard was just a second panel swapped into the same
quad as the menu launcher, which made the launcher translucent too and
made the keyboard and menu mutually exclusive. Extract the shared
swapchain/JNI/touch-dispatch plumbing into two generic, reusable
modules - xr_swapchain.c (swapchain + per-image FBO setup) and
xr_overlay.c (an off-screen Android View rendered into its own OpenXR
quad, hit-tested via a laser pointer) - and make the menu launcher and
keyboard two independent XrOverlay instances instead of one. The
controller's menu button now only opens/closes the launcher; the
launcher's "Keyboard" button only opens the keyboard - so the keyboard
can stay up while picking something from the menu, and only the
keyboard's own Close button hides it.

xr_menu.c/.h are gone, fully absorbed into xr_overlay.c. On the Java
side, OverlayPanel.java carries the shared off-screen-render/touch/
cursor plumbing that MenuOverlay and the new KeyboardOverlay both
subclass.
2026-08-14 06:43:44 +02:00
ml 2d8fb49bf5 Add hand-built on-screen keyboard to the OpenXR menu quad
build / build (push) Successful in 1m45s
MenuOverlay's key grid forwards every press to the game as real input:
non-printable keys (Esc/Enter/Backspace) as an SDLActivity.onNativeKeyDown()/
onNativeKeyUp() pair, and printable keys (letters, Space) as a synthesized
SDL_TEXTINPUT event pushed directly from native code, since the engine's
pump_events() only picks up printable ASCII from that event type, not from
SDL_KEYDOWN. Confirmed on-device, including that Space now correctly skips
the intro cutscene alongside Esc/Enter.
2026-08-05 08:11:16 +02:00
ml 1be0bebda2 Split the menu quad back onto its own independent OpenXR swapchain
build / build (push) Successful in 1m43s
Replaces the single shared, side-by-side swapchain (game + menu content
packed into one image, needing viewport/scissor juggling to keep the
menu's own glClear from bleeding into the game's region) with two fully
independent XrSwapchainState instances, each sized to exactly its own
content and acquired/released on its own within the same
xrBeginFrame/xrEndFrame pair - an ordinary multi-layer OpenXR setup.

The shared-swapchain design was originally adopted to work around what
looked like a Horizon OS compositor limitation with independent
swapchains, but that was diagnosed before the real root cause of the
"menu shows the game's content" bug was found (gl4es's fpe.c
unconditionally substituting its own shader onto the menu's draw call -
see README's Debugging notes). Since the actual fix routes the menu's
blit through a real, non-gl4es glBlitFramebuffer() call - orthogonal to
how many swapchains exist - splitting back onto two swapchains works
fine and removes the GL_SCISSOR_TEST workaround and sub-rectangle
offset math entirely. Confirmed on-device: both quads render correctly,
hit-testing and 90 FPS unaffected.

Also adds a visible cursor to the menu quad itself: xr_input.c now
tracks whichever hand's aim ray hits the menu each frame and forwards it
to a new MenuOverlay.nativeUpdateCursor(), which draws a small dot at
that position (composited through the same Bitmap the menu's own
content already goes through) - previously there was no visual feedback
at all showing where you were pointing before pulling the trigger.
2026-08-02 08:01:14 +02:00
ml 2b52b818e8 Added a make android-studio target that wraps ./run-image.sh bash build-image/prepare-android-project.sh --host-paths, and updated the README (Layout's Makefile-target list, "5.2. Building natively in Android Studio", and "5.3. Testing cycles") to reference it — including a note that it must be re-run after make clean specifically, since that's what just bit you. Run make android-studio now to fix the build.
build / build (push) Successful in 1m42s
2026-08-02 06:46:07 +02:00
28 changed files with 2927 additions and 980 deletions
+2
View File
@@ -0,0 +1,2 @@
* text=auto
*.sh text eol=lf
+19 -10
View File
@@ -1,14 +1,16 @@
name: build name: build
# Builds a versioned Linux release tarball (see `make package`) on every # Builds versioned Linux and Windows release packages (see `make
# push/PR, plus on-demand via the Gitea "Run workflow" button. Runs # package`, which builds both - a .tar.gz and a .zip) on every push/PR,
# plus on-demand via the Gitea "Run workflow" button. Runs
# inside the build-image (see ../../build-image/Dockerfile, built/pushed # inside the build-image (see ../../build-image/Dockerfile, built/pushed
# via ../../build-image.sh and ../../upload-image.sh), which bundles # via ../../build-image.sh and ../../upload-image.sh), which bundles
# every dependency engine/ needs to compile - no network access needed # every dependency engine/ needs to compile (for both platforms,
# at job runtime. Since the job's container already *is* the build-image # including the Windows cross-toolchain) - no network access needed at
# (QUESTSHOCK_BUILD_IMAGE=1), `make package`'s `engine` prerequisite # job runtime. Since the job's container already *is* the build-image
# compiles directly instead of trying to docker-run it again, which # (QUESTSHOCK_BUILD_IMAGE=1), `make package`'s `engine`/`engine-win`
# wouldn't work here (no nested docker). # prerequisites compile directly instead of trying to docker-run it
# again, which wouldn't work here (no nested docker).
on: on:
push: push:
pull_request: pull_request:
@@ -50,17 +52,22 @@ jobs:
# comment) - openxr was added to this image in the same # comment) - openxr was added to this image in the same
# Android/Quest layer, so it's an equally good marker of that. # Android/Quest layer, so it's an equally good marker of that.
[ -d /opt/prebuilt/android/openxr ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/android/openxr missing - runner is using a build-image older than the Android/Quest layer" >&2; exit 1; } [ -d /opt/prebuilt/android/openxr ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/android/openxr missing - runner is using a build-image older than the Android/Quest layer" >&2; exit 1; }
[ -d /opt/prebuilt/win/sdl2 ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/win/sdl2 missing - runner is using a build-image older than the Windows cross-build layer" >&2; exit 1; }
command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: x86_64-w64-mingw32-gcc not in PATH" >&2; exit 1; }
command -v zip >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: zip not in PATH (needed by make package-win)" >&2; exit 1; }
- name: Build package - name: Build package
run: make package run: make package
- name: Upload build artifact - name: Upload build artifacts
# v4 uses the newer @actions/artifact backend, which this Gitea # v4 uses the newer @actions/artifact backend, which this Gitea
# instance's artifact storage doesn't support (GHESNotSupportedError) - v3 works. # instance's artifact storage doesn't support (GHESNotSupportedError) - v3 works.
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: shockolate name: shockolate
path: dist/shockolate-*-linux-*.tar.gz path: |
dist/shockolate-*-linux-*.tar.gz
dist/shockolate-*-windows-*.zip
- name: Build Quest APK - name: Build Quest APK
# QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so # QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so
@@ -78,13 +85,14 @@ jobs:
path: dist/questshock-*-android-*.apk path: dist/questshock-*-android-*.apk
- name: Publish to dl.ladkau.de - name: Publish to dl.ladkau.de
# Uploads the tarball and APK over SFTP instead of using # Uploads the tarball, zip, and APK over SFTP instead of using
# actions/upload-artifact (whose zip wrapping can't be disabled). # actions/upload-artifact (whose zip wrapping can't be disabled).
# Only runs on push so PR builds don't publish. # Only runs on push so PR builds don't publish.
if: gitea.event_name == 'push' if: gitea.event_name == 'push'
run: | run: |
set -euo pipefail set -euo pipefail
TARBALL="$(ls dist/shockolate-*-linux-*.tar.gz)" TARBALL="$(ls dist/shockolate-*-linux-*.tar.gz)"
ZIP="$(ls dist/shockolate-*-windows-*.zip)"
APK="$(ls dist/questshock-*-android-*.apk)" APK="$(ls dist/questshock-*-android-*.apk)"
mkdir -p ~/.ssh mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
@@ -94,5 +102,6 @@ jobs:
uploader@dl.ladkau.de <<EOF uploader@dl.ladkau.de <<EOF
-mkdir files/questshock -mkdir files/questshock
put $TARBALL files/questshock/$(basename "$TARBALL") put $TARBALL files/questshock/$(basename "$TARBALL")
put $ZIP files/questshock/$(basename "$ZIP")
put $APK files/questshock/$(basename "$APK") put $APK files/questshock/$(basename "$APK")
EOF EOF
+6 -1
View File
@@ -3,16 +3,21 @@
# Game assets are not included in the repo # Game assets are not included in the repo
/res/assets/setup_system_shock_enhanced* /res/assets/setup_system_shock_enhanced*
/res/assets/*.zip
/res/assets/ss_ee/ /res/assets/ss_ee/
# Build output # Build output
/dist/ /dist/
/dist-win/
/build/ /build/
# Engine build artifacts (engine/ is committed as a source snapshot; these # Engine build artifacts (engine/ is committed as a source snapshot; these
# are generated by run-image.sh / build-image/build-engine.sh) # are generated by run-image.sh / build-image/build-engine.sh /
# build-image/build-engine-win.sh - the Windows cross-build's own scratch
# copy of engine/ lives under /build/win-engine/, already covered above)
/engine/build_ext/ /engine/build_ext/
/engine/.build-output/ /engine/.build-output/
/engine/.build-output-win/
/engine/CMakeCache.txt /engine/CMakeCache.txt
/engine/CMakeFiles/ /engine/CMakeFiles/
/engine/cmake_install.cmake /engine/cmake_install.cmake
+94 -10
View File
@@ -1,20 +1,28 @@
# Assembles dist/ - a self-contained, runnable copy of System Shock - # Assembles dist/ (Linux) and dist-win/ (Windows, cross-compiled via
# from the compiled engine (built via Docker, see build-image.sh/ # MinGW) - self-contained, runnable copies of System Shock - from the
# run-image.sh) and the game assets extracted from a purchased copy (see # compiled engine (built via Docker, see build-image.sh/run-image.sh) and
# the game assets extracted from a purchased copy (see
# res/assets/extract_assets.sh). Building the engine needs the build-image # res/assets/extract_assets.sh). Building the engine needs the build-image
# (./build-image.sh, once); everything else here is plain file copying. # (./build-image.sh, once); everything else here is plain file copying.
# `dist-linux`/`dist-win` build just one platform; plain `dist` builds
# both.
# #
# `make package` instead builds a redistributable tarball that omits the # `make package` instead builds redistributable archives (a .tar.gz for
# proprietary game assets entirely (see res/assets/GET_ASSETS.txt, which # Linux, a .zip for Windows) that omit the proprietary game assets
# it ships in their place) - this is what CI publishes. # entirely (see res/assets/GET_ASSETS.txt, which they ship in their
# place) - this is what CI publishes. Same dist-linux/dist-win/(both)
# split via package-linux/package-win/package.
DIST_DIR := dist DIST_DIR := dist
DIST_WIN_DIR := dist-win
ENGINE_OUT := engine/.build-output ENGINE_OUT := engine/.build-output
ENGINE_OUT_WIN := engine/.build-output-win
ASSETS_DIR := res/assets/ss_ee ASSETS_DIR := res/assets/ss_ee
BUILD_DIR := build BUILD_DIR := build
ARCH := $(shell uname -m) ARCH := $(shell uname -m)
.PHONY: all dist build-image engine assets package apk clean .PHONY: all dist dist-linux dist-win build-image engine engine-win assets \
package package-linux package-win apk android-studio clean
all: dist all: dist
@@ -40,6 +48,16 @@ engine:
./run-image.sh; \ ./run-image.sh; \
fi fi
# Cross-compiles engine/ for Windows via MinGW - see build-engine-win.sh
# for why this can't share build-engine.sh's engine/build_ext/ (a scratch
# copy is used instead). Same QUESTSHOCK_BUILD_IMAGE detection as `engine`.
engine-win:
@if [ -n "$$QUESTSHOCK_BUILD_IMAGE" ]; then \
bash build-image/build-engine-win.sh; \
else \
./run-image.sh bash build-image/build-engine-win.sh; \
fi
# Fails with a pointer to extract_assets.sh if the purchased game assets # Fails with a pointer to extract_assets.sh if the purchased game assets
# haven't been extracted yet. # haven't been extracted yet.
assets: assets:
@@ -49,7 +67,10 @@ assets:
exit 1; \ exit 1; \
fi fi
dist: engine assets # Builds both platforms. `dist-linux`/`dist-win` build just one.
dist: dist-linux dist-win
dist-linux: engine assets
@echo "== Assembling $(DIST_DIR) ==" @echo "== Assembling $(DIST_DIR) =="
rm -rf "$(DIST_DIR)/systemshock" "$(DIST_DIR)/lib" "$(DIST_DIR)/res" \ rm -rf "$(DIST_DIR)/systemshock" "$(DIST_DIR)/lib" "$(DIST_DIR)/res" \
"$(DIST_DIR)/shaders" "$(DIST_DIR)/run.sh" "$(DIST_DIR)/shaders" "$(DIST_DIR)/run.sh"
@@ -64,6 +85,27 @@ dist: engine assets
chmod +x "$(DIST_DIR)/run.sh" chmod +x "$(DIST_DIR)/run.sh"
@echo "== Done - run $(DIST_DIR)/run.sh to play ==" @echo "== Done - run $(DIST_DIR)/run.sh to play =="
# Windows counterpart of dist-linux. Unlike dist/, the DLLs sit flat
# alongside systemshock.exe instead of in a lib/ subdirectory - Windows'
# default DLL search order already checks the executable's own directory
# first, so (unlike run.sh's LD_LIBRARY_PATH) run.bat needs no extra
# wiring for that.
dist-win: engine-win assets
@echo "== Assembling $(DIST_WIN_DIR) =="
rm -rf "$(DIST_WIN_DIR)"
mkdir -p "$(DIST_WIN_DIR)/res/data" "$(DIST_WIN_DIR)/res/sound"
cp "$(ENGINE_OUT_WIN)/systemshock.exe" "$(ENGINE_OUT_WIN)"/*.dll "$(DIST_WIN_DIR)/"
cp "$(ENGINE_OUT_WIN)/soundfont.sf2" "$(DIST_WIN_DIR)/res/"
cp -a engine/shaders "$(DIST_WIN_DIR)/shaders"
cp -a "$(ASSETS_DIR)/data/." "$(DIST_WIN_DIR)/res/data/"
cp -a "$(ASSETS_DIR)/sound/." "$(DIST_WIN_DIR)/res/sound/"
cp res/run.bat "$(DIST_WIN_DIR)/run.bat"
@echo "== Done - run $(DIST_WIN_DIR)/run.bat (or systemshock.exe) to play =="
# Builds both platforms' redistributable archives. `package-linux`/
# `package-win` build just one.
package: package-linux package-win
# Builds a versioned, redistributable Linux release tarball at # Builds a versioned, redistributable Linux release tarball at
# dist/shockolate-<version>-linux-<arch>.tar.gz - everything needed to # dist/shockolate-<version>-linux-<arch>.tar.gz - everything needed to
# run except the proprietary game assets (res/GET_ASSETS.txt explains how # run except the proprietary game assets (res/GET_ASSETS.txt explains how
@@ -72,7 +114,7 @@ dist: engine assets
# tag to drive a release. Override with `make package VERSION=1.2.3`, or # tag to drive a release. Override with `make package VERSION=1.2.3`, or
# just run it untagged for a local dev build (gets a 0.0.0-dev+<sha> # just run it untagged for a local dev build (gets a 0.0.0-dev+<sha>
# placeholder version, with a warning). # placeholder version, with a warning).
package: engine package-linux: engine
@git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true @git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true
@V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \ @V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \
PKG_NAME="shockolate-$$V-linux-$(ARCH)"; \ PKG_NAME="shockolate-$$V-linux-$(ARCH)"; \
@@ -95,6 +137,33 @@ package: engine
rm -rf "$(BUILD_DIR)/package"; \ rm -rf "$(BUILD_DIR)/package"; \
echo "Wrote $(DIST_DIR)/$$PKG_NAME.tar.gz" echo "Wrote $(DIST_DIR)/$$PKG_NAME.tar.gz"
# Windows counterpart of package-linux: dist/shockolate-<version>-windows-
# x86_64.zip. Same versioning (build-image/version.sh) and VERSION=
# override. Needs `zip` on whatever host runs `make package-win` itself
# (unlike engine-win's own build, packaging isn't run inside the
# build-image) - see README's Desktop build prerequisites.
package-win: engine-win
@git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true
@command -v zip >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: zip not found in PATH" >&2; exit 1; }
@V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \
PKG_NAME="shockolate-$$V-windows-x86_64"; \
PKG_STAGE="$(BUILD_DIR)/package/$$PKG_NAME"; \
echo "Packaging $$PKG_NAME"; \
rm -rf "$$PKG_STAGE"; \
mkdir -p "$$PKG_STAGE/res"; \
cp "$(ENGINE_OUT_WIN)/systemshock.exe" "$(ENGINE_OUT_WIN)"/*.dll "$$PKG_STAGE/"; \
cp -a engine/shaders "$$PKG_STAGE/shaders"; \
cp "$(ENGINE_OUT_WIN)/soundfont.sf2" "$$PKG_STAGE/res/"; \
cp res/assets/GET_ASSETS.txt "$$PKG_STAGE/res/GET_ASSETS.txt"; \
cp res/run.bat "$$PKG_STAGE/run.bat"; \
cp LICENSE "$$PKG_STAGE/LICENSE"; \
cp engine/LICENSE "$$PKG_STAGE/LICENSE.Shockolate"; \
cp NOTICE.txt "$$PKG_STAGE/NOTICE.txt"; \
mkdir -p "$(DIST_DIR)"; \
(cd "$(BUILD_DIR)/package" && zip -rq "$(CURDIR)/$(DIST_DIR)/$$PKG_NAME.zip" "$$PKG_NAME"); \
rm -rf "$(BUILD_DIR)/package"; \
echo "Wrote $(DIST_DIR)/$$PKG_NAME.zip"
# Builds the Quest APK (see build-image/build-apk.sh and # Builds the Quest APK (see build-image/build-apk.sh and
# android/engine-patches/ - engine/ itself is never modified; a patch is # android/engine-patches/ - engine/ itself is never modified; a patch is
# applied to a scratch copy at build time instead). Same # applied to a scratch copy at build time instead). Same
@@ -108,8 +177,23 @@ apk:
./run-image.sh bash build-image/build-apk.sh; \ ./run-image.sh bash build-image/build-apk.sh; \
fi fi
# Stages a scratch, patched copy of engine/ and android/gl4es-src/, plus
# the Android SDL2/SDL2_mixer/fluidsynth-lite/openxr prebuilts, with
# host-resolvable paths (writes android/engine.properties) so Android
# Studio can compile and deploy android/ natively - see README's "5.2.
# Building natively in Android Studio". `make apk` doesn't need this; it
# stages the same things itself inside the build-image. Re-run after
# `make clean` (which deletes android/engine.properties) or after
# changing engine/, android/engine-patches/, android/gl4es-src/,
# android/gl4es-patches/, or the prebuilt-library versions in
# build-image/Dockerfile - Android Studio's own Gradle build already
# does this automatically via its stageEngine task, so this is mainly
# useful for first-time setup or confirming staging succeeded on its own.
android-studio:
./run-image.sh bash build-image/prepare-android-project.sh --host-paths
clean: clean:
rm -rf "$(DIST_DIR)" "$(BUILD_DIR)" "$(ENGINE_OUT)" \ rm -rf "$(DIST_DIR)" "$(DIST_WIN_DIR)" "$(BUILD_DIR)" "$(ENGINE_OUT)" "$(ENGINE_OUT_WIN)" \
engine/build_ext engine/CMakeCache.txt engine/CMakeFiles \ engine/build_ext engine/CMakeCache.txt engine/CMakeFiles \
engine/cmake_install.cmake engine/Makefile engine/systemshock \ engine/cmake_install.cmake engine/Makefile engine/systemshock \
engine/src/Libraries/CMakeFiles \ engine/src/Libraries/CMakeFiles \
+16
View File
@@ -16,3 +16,19 @@ The Android build additionally bundles:
- The Khronos Group's OpenXR-SDK loader - The Khronos Group's OpenXR-SDK loader
(https://github.com/KhronosGroup/OpenXR-SDK), prebuilt unmodified as (https://github.com/KhronosGroup/OpenXR-SDK), prebuilt unmodified as
lib/arm64-v8a/libopenxr_loader.so. It is Apache 2.0-licensed. lib/arm64-v8a/libopenxr_loader.so. It is Apache 2.0-licensed.
The Windows build (cross-compiled via MinGW - see README's "4.2. Windows
cross-build") additionally bundles:
- GLEW (http://glew.sourceforge.net/), unmodified, as glew32.dll - used
for OpenGL extension loading, which Windows' own opengl32.dll doesn't
provide past OpenGL 1.1 (Linux instead gets this straight from Mesa's
headers, needing no separate loader library - see
engine/src/MacSrc/OpenGL.cc). It is licensed under a combination of the
Modified BSD License, the MIT License, and the Khronos License, all
permissive.
- The MinGW-w64 runtime's winpthreads library, as libwinpthread-1.dll. It
is MIT-licensed. (GCC's own runtime, libgcc/libstdc++, is linked
statically into systemshock.exe instead of shipped as a DLL, under the
GCC Runtime Library Exception - this doesn't subject the rest of the
binary to the GPL.)
+143 -28
View File
@@ -52,6 +52,11 @@ a cross-platform port of the original game.
Android-based OpenXR headsets. Unlike every other target, this needs Android-based OpenXR headsets. Unlike every other target, this needs
network access at build time (Gradle/AGP's own dependency network access at build time (Gradle/AGP's own dependency
resolution). See "5. Android / Quest build" below. resolution). See "5. Android / Quest build" below.
- `android-studio` - stages `engine/`/`android/gl4es-src/` (scratch,
patched copies) and the Android prebuilts with host-resolvable paths,
so Android Studio can compile and deploy `android/` natively instead
of inside the build-image. See "5.2. Building natively in Android
Studio" below.
- `clean` - removes all build output (`dist/`, `build/`, compiled - `clean` - removes all build output (`dist/`, `build/`, compiled
engine artifacts, and the staged Android project files). engine artifacts, and the staged Android project files).
- `android/` - the Quest app (Java `SDLActivity` glue, Gradle project). - `android/` - the Quest app (Java `SDLActivity` glue, Gradle project).
@@ -87,10 +92,30 @@ below.
## 4. Desktop build ## 4. Desktop build
Builds and runs Questshock natively on Linux (your dev machine, or any Builds and runs Questshock natively on the desktop (your dev machine, or
Linux box) - useful for local development and testing without a VR any Linux/Windows box) - useful for local development and testing
headset at all. For the VR-headset build, see "5. Android / Quest build" without a VR headset at all. For the VR-headset build, see "5. Android /
below instead. Quest build" below instead.
Two platforms: Linux (native) and Windows (x86_64, cross-compiled via
MinGW - see "4.2. Windows cross-build" below for how). `make dist`/`make
package` build **both** by default; use `make dist-linux`/`make
dist-win` (or `package-linux`/`package-win`) to build just one.
**Prerequisites:**
- **Docker** - `build-image.sh`/`run-image.sh` build and run the engine
build-image; no other build tool touches the host directly. The
Windows cross-toolchain (MinGW) is baked into the same build-image, so
building `dist-win` needs nothing extra beyond Docker itself.
- **`innoextract` and `unzip`** - optional, only needed by
`res/assets/extract_assets.sh` (see "3. Game assets" above) to unpack
the GOG installer locally; if either is missing, that script falls
back to running the extraction inside a throwaway Docker container
instead.
- **`zip`** - only needed on the host for `make package`/`package-win`
(the Windows release archive); `make dist`/`dist-win` and
`package-linux` don't need it.
```sh ```sh
# 1. Build the engine build-image (once, or after build-image/ changes) # 1. Build the engine build-image (once, or after build-image/ changes)
@@ -99,11 +124,12 @@ below instead.
# 2. Get your own copy of the game data (see "3. Game assets" above), then: # 2. Get your own copy of the game data (see "3. Game assets" above), then:
res/assets/extract_assets.sh res/assets/extract_assets.sh
# 3. Compile the engine and assemble dist/ # 3. Compile the engine and assemble dist/ (Linux) and dist-win/ (Windows)
make dist make dist
# 4. Play # 4. Play
dist/run.sh dist/run.sh # Linux
dist-win/run.bat # Windows (or systemshock.exe directly)
``` ```
`make dist` always recompiles the engine from the current `engine/` `make dist` always recompiles the engine from the current `engine/`
@@ -112,14 +138,38 @@ source (via `run-image.sh`), so a fresh build-image plus a re-run of
### 4.1. Packaging a distributable build ### 4.1. Packaging a distributable build
`make package` builds `dist/shockolate-<version>-linux-<arch>.tar.gz`: the `make package` builds both `dist/shockolate-<version>-linux-<arch>.tar.gz`
compiled binary, its runtime libraries, shaders, a default MIDI and `dist/shockolate-<version>-windows-x86_64.zip`: the compiled
soundfont, license information, and `res/GET_ASSETS.txt` in place of the binary/DLLs, shaders, a default MIDI soundfont, license information, and
actual game data (which the tarball never includes). Version comes from `res/GET_ASSETS.txt` in place of the actual game data (which neither
the current git tag (push a `vX.Y.Z` tag to drive a release); without one archive ever includes). Version comes from the current git tag (push a
it builds an untagged `0.0.0-dev+<sha>` placeholder. `make apk` (see `vX.Y.Z` tag to drive a release); without one it builds an untagged
"5. Android / Quest build" below) is versioned identically, via the same `0.0.0-dev+<sha>` placeholder. `make apk` (see "5. Android / Quest build"
`build-image/version.sh`. below) is versioned identically, via the same `build-image/version.sh`.
### 4.2. Windows cross-build
`make dist-win`/`make package-win` cross-compile a native Windows x86_64
build via MinGW (`x86_64-w64-mingw32-gcc`/`g++`, baked into the
build-image alongside the Linux toolchain - see
`build-image/build-engine-win.sh` and the "Windows cross-compile" section
of `build-image/Dockerfile`) - no Windows machine, Wine, or VM involved
in building it. `engine/CMakeLists.txt` already has working `WIN32`/
`MINGW` branches from Shockolate's own upstream Windows build (built
natively via Git Bash/MinGW on a real Windows machine - see
`engine/build_win64.sh`/`engine/appveyor.yml`), so unlike the Quest
build's `android/engine-patches/`, no source patching is needed here -
this cross-compiles those same branches offline and reproducibly, from
Linux, via Docker.
`dist-win/`/the packaged `.zip` ship `systemshock.exe` with its DLLs
(SDL2, SDL2_mixer, GLEW, fluidsynth-lite, plus the MinGW pthread runtime)
sitting flat alongside it, rather than in a `lib/` subdirectory like
`dist/` - Windows' default DLL search order already checks the
executable's own directory first, so no `PATH`/library-path setup is
needed the way `dist/run.sh` needs `LD_LIBRARY_PATH`.
Confirmed working (and playable) on a real Windows machine.
A Gitea Actions workflow (`.gitea/workflows/build.yml`) builds this A Gitea Actions workflow (`.gitea/workflows/build.yml`) builds this
package on every push, using the build-image as its container (so no package on every push, using the build-image as its container (so no
@@ -128,6 +178,18 @@ resulting tarball to dl.ladkau.de.
## 5. Android / Quest build ## 5. Android / Quest build
**Prerequisites:**
- **Docker** - same build-image as the desktop build (see "4. Desktop
build" above), plus network access at build time for Gradle/AGP's own
dependency resolution (the one target that isn't fully offline).
- **SideQuest or `adb`** (Android Platform Tools) - to sideload the built
APK onto the headset, and to enable/verify USB debugging; see "5.1.
Installing and playing" below.
- Building/debugging natively in Android Studio instead of via `make
apk` needs its own separate toolchain - see "5.2. Building natively in
Android Studio" below.
`make apk` builds `dist/questshock-<version>-android-arm64.apk` - an `make apk` builds `dist/questshock-<version>-android-arm64.apk` - an
immersive OpenXR app (see `android/app/src/main/cpp/xr_session.c`) that immersive OpenXR app (see `android/app/src/main/cpp/xr_session.c`) that
can be sideloaded onto any Android-based VR headset with OpenXR support can be sideloaded onto any Android-based VR headset with OpenXR support
@@ -135,12 +197,62 @@ can be sideloaded onto any Android-based VR headset with OpenXR support
vendor's store. The game's own rendering is unchanged (still a flat, 2D vendor's store. The game's own rendering is unchanged (still a flat, 2D
render, no stereo 3D scene), but instead of running as a Home-hosted 2D render, no stereo 3D scene), but instead of running as a Home-hosted 2D
panel it's now shown as a single head-tracked quad floating in front of panel it's now shown as a single head-tracked quad floating in front of
the viewer in an otherwise empty space. There's no controller-driven the viewer, with a separate menu quad toggled by a controller button and
interaction yet (that's ongoing work - a laser-pointer-driven menu and driven by a laser-pointer-style aim ray from each hand
on-screen keyboard); for now, play with a Bluetooth mouse/keyboard (`android/app/src/main/cpp/xr_input.c`) - point and pull the trigger to
connected to the headset same as before. (Only tested on Meta Quest so interact with it, same as the game's own Bluetooth mouse/keyboard input
far - the steps below use Quest-specific tool names where relevant, but otherwise works unchanged.
the same `adb install` flow applies to any Android headset with USB
The menu launcher (`MenuOverlay.java` - just a "Keyboard" button so far)
and the keyboard (`KeyboardOverlay.java`) are two fully independent quads,
each its own `XrOverlay` instance (`android/app/src/main/cpp/xr_overlay.c`
- a generic "off-screen Android View rendered into an OpenXR quad,
hit-tested via a laser pointer" module shared by both, and by whatever
similar panel comes next) with its own swapchain, visibility, and
position, so they can be shown/hidden/moved independently - a design
specifically meant to let the keyboard stay open while also picking
something from the (still-to-be-built-out) menu. The controller's menu
button only ever opens/closes the launcher; clicking its "Keyboard" button
only opens the keyboard (never touching the launcher's own visibility) -
the keyboard's own title bar holds the only way to close it again.
The keyboard is a hand-built, full US-layout key grid (there's no system
IME to borrow once immersive) that forwards each key press straight to the
game as real input - non-printable keys (Esc/Enter/Backspace/Tab/arrows/
F1-F12) as a `SDLActivity.onNativeKeyDown()`/`onNativeKeyUp()` pair, the
same one a physical Bluetooth keyboard's presses already go through, and
printable keys (letters, digits, punctuation, Space) via a synthesized
`SDL_TEXTINPUT` event pushed directly from native code
(`questshock_native.c`). A Shift key toggles the whole grid between
lowercase/numbers and uppercase/symbols (doubling as Caps Lock - it stays
toggled until pressed again), and Ctrl/Alt arm as one-shot modifiers,
consumed by whichever key is pressed next - meant as a general stand-in
for keyboard-driven functionality that isn't (yet, or ever) mapped onto
the controllers, not just a future config screen's text entry.
Once opened, the keyboard stays up as a standing input panel while
actually playing rather than a modal you open and close. Its title bar
doubles as a drag handle (grab-and-drag with the trigger, handled entirely
on the native side in `xr_input.c` before it ever reaches
`KeyboardOverlay`'s own touch dispatch) for repositioning it, and holds
the Close button that hides it. It's also rendered semi-transparent
(`XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT` in `xr_session.c`,
set only on the keyboard's layer - the menu launcher stays fully opaque)
so whatever's behind it - the game quad, mid-play - stays visible while
it's up.
While the menu launcher or keyboard is open, each hand also gets a thin,
colored laser-beam quad (cyan left, amber right) from the controller to
wherever its ray currently crosses that panel's plane - a second,
billboarded `XrCompositionLayerQuad` per hand (`xr_input_build_beam()` in
`xr_input.c`), oriented so it reads as a line from the viewer's eye
regardless of angle, since the flat cross-shaped reticle used while
aiming at the game quad is drawn directly into the game quad's own
texture and can't represent a ray traversing real 3D space. Aiming at the
game quad itself still uses that flat reticle, unchanged. (Only tested on
Meta Quest so far -
the steps below use Quest-specific tool names where relevant, but the
same `adb install` flow applies to any Android headset with USB
debugging enabled.) debugging enabled.)
### 5.1. Installing and playing ### 5.1. Installing and playing
@@ -168,11 +280,12 @@ that happens inside a container it isn't running.
To have Android Studio compile and deploy `android/` itself instead: To have Android Studio compile and deploy `android/` itself instead:
```sh ```sh
./run-image.sh bash build-image/prepare-android-project.sh --host-paths make android-studio
``` ```
This stages everything `make apk` normally stages (scratch, patched copies (equivalent to `./run-image.sh bash build-image/prepare-android-project.sh
of `engine/` and `android/gl4es-src/`; the Android --host-paths` directly.) This stages everything `make apk` normally stages
(scratch, patched copies of `engine/` and `android/gl4es-src/`; the Android
SDL2/SDL2_mixer/fluidsynth-lite/openxr prebuilts; bundled assets) - the SDL2/SDL2_mixer/fluidsynth-lite/openxr prebuilts; bundled assets) - the
same as `build-apk.sh`'s own prep step - except it writes same as `build-apk.sh`'s own prep step - except it writes
`android/engine.properties` with paths that resolve on your host `android/engine.properties` with paths that resolve on your host
@@ -184,12 +297,13 @@ Studio's own native build (a CMake subdirectory of `engine/`'s build, not
a separate prebuilt step), so `android/gl4es-src/`/`android/gl4es-patches/` a separate prebuilt step), so `android/gl4es-src/`/`android/gl4es-patches/`
changes are picked up by a normal rebuild here - no need to re-run changes are picked up by a normal rebuild here - no need to re-run
`./build-image.sh` first, unlike `build-image/Dockerfile` changes. Re-run `./build-image.sh` first, unlike `build-image/Dockerfile` changes. Re-run
this script whenever `engine/`, `android/engine-patches/`, `make android-studio` whenever `engine/`, `android/engine-patches/`,
`android/gl4es-src/`, `android/gl4es-patches/`, or the prebuilt-library `android/gl4es-src/`, `android/gl4es-patches/`, or the prebuilt-library
versions in `build-image/Dockerfile` change (the Gradle build already versions in `build-image/Dockerfile` change (the Gradle build already
does this automatically for you via its `stageEngine` task, so this does this automatically for you via its `stageEngine` task, so this
manual re-run is mainly useful for confirming staging succeeded on its manual re-run is mainly useful for confirming staging succeeded on its
own). own) - and always after `make clean`, which deletes
`android/engine.properties` along with everything else.
Then, in Android Studio, use File > Open and select the `android/` Then, in Android Studio, use File > Open and select the `android/`
directory itself (the one containing `settings.gradle` - not the repo directory itself (the one containing `settings.gradle` - not the repo
@@ -226,7 +340,7 @@ the same as any other Android Studio project:
`QuestShock`, so filtering Logcat by that tag shows everything `QuestShock`, so filtering Logcat by that tag shows everything
questshock-specific without gl4es/OpenXR loader/system noise (drop questshock-specific without gl4es/OpenXR loader/system noise (drop
the filter to see those too). the filter to see those too).
6. Only re-run `prepare-android-project.sh` manually (see above) after 6. Only re-run `make android-studio` manually (see above) after
changing `engine/`, `android/engine-patches/`, `android/gl4es-src/`, changing `engine/`, `android/engine-patches/`, `android/gl4es-src/`,
`android/gl4es-patches/`, or the prebuilt-library versions in `android/gl4es-patches/`, or the prebuilt-library versions in
`build-image/Dockerfile` - the Gradle build's `stageEngine` task does `build-image/Dockerfile` - the Gradle build's `stageEngine` task does
@@ -299,8 +413,9 @@ each one does:
#### 5.5.1. The menu quad rendering the game instead of itself #### 5.5.1. The menu quad rendering the game instead of itself
While building the OpenXR menu (`android/app/src/main/cpp/xr_menu.c`, While building the OpenXR menu (`android/app/src/main/cpp/xr_overlay.c` -
`MenuOverlay.java`), the menu's composition-layer quad consistently showed `xr_menu.c` at the time - and `MenuOverlay.java`), the menu's
composition-layer quad consistently showed
the game's own live rendering instead of the menu's content, even though the game's own live rendering instead of the menu's content, even though
every diagnostic (FBO bindings, swapchain/layer submission, texture every diagnostic (FBO bindings, swapchain/layer submission, texture
upload, viewport/scissor state) checked out correct in isolation. upload, viewport/scissor state) checked out correct in isolation.
+1 -1
View File
@@ -171,7 +171,7 @@ android {
"-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \ "-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \
"-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \ "-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \
"-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384", \ "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384", \
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_menu.c" "-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c;${projectDir}/src/main/cpp/xr_mouse.c"
abiFilters 'arm64-v8a' abiFilters 'arm64-v8a'
} }
} }
@@ -7,9 +7,53 @@
#include <jni.h> #include <jni.h>
#include <unistd.h> #include <unistd.h>
#include <SDL.h>
#include "xr_overlay.h"
#include "xr_session.h"
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass clazz, jstring path) { Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass clazz, jstring path) {
const char *cpath = (*env)->GetStringUTFChars(env, path, NULL); const char *cpath = (*env)->GetStringUTFChars(env, path, NULL);
chdir(cpath); chdir(cpath);
(*env)->ReleaseStringUTFChars(env, path, cpath); (*env)->ReleaseStringUTFChars(env, path, cpath);
} }
// KeyboardOverlay's hand-built on-screen keyboard (see
// KeyboardOverlay.commitPrintableChar()) has no real Android IME session
// behind it, so printable characters can't reach the game via Android's
// usual IME path - instead this builds and pushes an SDL_TEXTINPUT event
// directly, the same event type/shape SDL's own Android backend pushes for
// typed text, using only the public SDL_Event/SDL_PushEvent API. The
// engine's pump_events() (sdl_events.c) only picks up printable characters
// from this event, not from SDL_KEYDOWN (see handleKeyPress() in
// KeyboardOverlay.java for why).
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_KeyboardOverlay_nativeSendPrintableChar(JNIEnv *env, jclass clazz,
jchar c) {
SDL_Event event;
SDL_zero(event);
event.type = SDL_TEXTINPUT;
event.text.timestamp = SDL_GetTicks();
event.text.windowID = 0;
event.text.text[0] = (char)c;
event.text.text[1] = '\0';
SDL_PushEvent(&event);
}
// KeyboardOverlay's title-bar Close button - the keyboard is a fully
// independent overlay quad from the menu launcher (see xr_overlay.h), so
// this is the only way to hide it once it's open; the controller's menu
// button only ever affects the menu launcher.
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_KeyboardOverlay_nativeRequestClose(JNIEnv *env, jclass clazz) {
xr_overlay_set_visible(xr_session_get_keyboard_overlay(), false);
}
// MenuOverlay's "Keyboard" button - only opens the keyboard overlay, never
// touches the menu launcher's own visibility, so both can be shown
// together.
JNIEXPORT void JNICALL
Java_de_ladkau_questshock_MenuOverlay_nativeShowKeyboard(JNIEnv *env, jclass clazz) {
xr_overlay_set_visible(xr_session_get_keyboard_overlay(), true);
}
+545 -138
View File
@@ -7,8 +7,10 @@
#include <GLES3/gl3.h> #include <GLES3/gl3.h>
#include "xr_menu.h" #include "xr_mouse.h"
#include "xr_overlay.h"
#include "xr_session.h" #include "xr_session.h"
#include "xr_swapchain.h"
#define TAG "QuestShock" #define TAG "QuestShock"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
@@ -17,6 +19,24 @@
#define LEFT 0 #define LEFT 0
#define RIGHT 1 #define RIGHT 1
static const char *kHandName[2] = {"left", "right"};
static const float kHandColor[2][4] = {
{0.2f, 1.0f, 1.0f, 1.0f}, // left: cyan
{1.0f, 0.85f, 0.1f, 1.0f}, // right: amber
};
// The keyboard overlay's title bar (drag handle + Close button) is handled
// entirely here, before a hit would normally be dispatched to
// KeyboardOverlay as a touch - these fractions must agree with
// KeyboardOverlay.java's TITLE_BAR_HEIGHT/CLOSE_BUTTON_WIDTH constants (in
// pixels, out of KeyboardOverlay.WIDTH/HEIGHT there). A hit in the top
// TITLE_BAR_V_FRACTION of the quad, at or left of CLOSE_BUTTON_U_FRACTION,
// is the drag handle; right of that (still within the title bar) is the
// Close button, which is dispatched as an ordinary click instead. The menu
// launcher overlay has no title bar - it's a plain click-only panel.
#define TITLE_BAR_V_FRACTION (90.0f / 768.0f)
#define CLOSE_BUTTON_U_FRACTION (1.0f - 160.0f / 1024.0f)
static XrInstance g_instance = XR_NULL_HANDLE; static XrInstance g_instance = XR_NULL_HANDLE;
static XrSession g_session = XR_NULL_HANDLE; static XrSession g_session = XR_NULL_HANDLE;
static XrActionSet g_action_set = XR_NULL_HANDLE; static XrActionSet g_action_set = XR_NULL_HANDLE;
@@ -27,17 +47,53 @@ static XrPath g_hand_path[2];
static XrSpace g_aim_space[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE}; static XrSpace g_aim_space[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE};
// Edge-detection state, so touch dispatch and logging only fire on actual // Edge-detection state, so touch dispatch and logging only fire on actual
// state changes, not every frame. // state changes, not every frame. Each interaction target (game quad, menu
// launcher, keyboard) tracks its own "was this hand's ray hitting it last
// frame" independently, since the menu and keyboard overlays can now both
// be visible at once.
static bool g_prev_select[2] = {false, false}; static bool g_prev_select[2] = {false, false};
static bool g_prev_menu = false; static bool g_prev_menu = false;
static bool g_prev_hit[2] = {false, false}; static bool g_game_prev_hit[2] = {false, false};
// Tracks which hand currently has an in-flight synthetic touch down on the static bool g_game_touch_active[2] = {false, false};
// menu (so a later trigger-up edge only dispatches a matching touch-up if static bool g_menu_prev_hit[2] = {false, false};
// a touch-down was actually sent for that hand).
static bool g_menu_touch_active[2] = {false, false}; static bool g_menu_touch_active[2] = {false, false};
static bool g_keyboard_prev_hit[2] = {false, false};
static bool g_keyboard_touch_active[2] = {false, false};
static GLuint g_reticle_program = 0; // Drag state for repositioning the keyboard overlay via its title bar (see
static GLint g_reticle_color_loc = -1; // TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION above) - the menu launcher
// has no title bar, so it's never draggable. g_keyboard_drag_hand is -1
// when g_keyboard_dragging is false. The offset is the vector from the
// quad's center to the grabbed point at drag-start, kept constant for the
// whole gesture so the quad follows the hand rigidly rather than
// re-centering under the ray each frame.
static bool g_keyboard_dragging = false;
static int g_keyboard_drag_hand = -1;
static float g_keyboard_drag_offset_x = 0.0f;
static float g_keyboard_drag_offset_y = 0.0f;
// A "view" reference space, located once per frame (not per hand) to get
// an approximate head position for the laser-beam billboard math below -
// the only consumer of a head pose in this file. Both LOCAL (g_local_space,
// owned by xr_session.c) and VIEW reference spaces are mandated by core
// OpenXR, so no capability check is needed to create this.
static XrSpace g_view_space = XR_NULL_HANDLE;
// Per-hand laser-beam state: a thin, billboarded quad spanning from the
// controller to wherever that hand's ray currently crosses the plane of
// whichever target (keyboard, menu, or the game quad) claimed it this
// frame - see xr_input_build_beam()/xr_input_get_beam_layer(). Each hand
// gets its own tiny solid-color swapchain (no shared tint on XrCompositionLayerQuad,
// so two separately-colored textures is simplest). g_beam_quad_valid is
// reset to false at the top of every xr_input_sync_and_draw() call and
// only set back to true if that hand actually claims a beam this frame.
#define BEAM_TEX_SIZE 2
#define BEAM_THICKNESS_METERS 0.004f
#define MAX_BEAM_LENGTH_METERS 2.0f
static XrSwapchainState g_beam_swapchain[2];
static XrCompositionLayerQuad g_beam_quad[2];
static bool g_beam_quad_valid[2] = {false, false};
static bool xr_check(XrResult result, const char *what) { static bool xr_check(XrResult result, const char *what) {
if (XR_SUCCEEDED(result)) if (XR_SUCCEEDED(result))
@@ -62,64 +118,71 @@ static void quat_rotate_vec(const XrQuaternionf *q, float vx, float vy, float vz
*outz = vz + 2.0f * (q->x * cy - q->y * cx); *outz = vz + 2.0f * (q->x * cy - q->y * cx);
} }
static GLuint compile_shader(GLenum type, const char *src) { static void vec3_sub(const float a[3], const float b[3], float out[3]) {
GLuint shader = glCreateShader(type); out[0] = a[0] - b[0];
glShaderSource(shader, 1, &src, NULL); out[1] = a[1] - b[1];
glCompileShader(shader); out[2] = a[2] - b[2];
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (!compiled) {
char log[512];
glGetShaderInfoLog(shader, sizeof(log), NULL, log);
LOGE("XR: reticle shader compile failed: %s", log);
}
return shader;
} }
// A standalone flat-color shader, independent of the engine's own static float vec3_dot(const float a[3], const float b[3]) {
// textureShaderProgram (OpenGL.cc is a separate, C++-only translation return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
// unit, and shader state isn't shared across programs anyway) - position }
// is emitted directly in clip space, matching the same [-1,1] local quad
// coordinates android_draw_surface_as_quad() (see android/engine-patches/
// 11-android-openxr-present.patch) already uses for its own vertex
// positions, so no view/projection matrix is needed here either.
static const char *kVertexSrc = "attribute vec3 position;\n"
"void main() { gl_Position = vec4(position, 1.0); }\n";
static const char *kFragmentSrc = "precision mediump float;\n"
"uniform vec4 color;\n"
"void main() { gl_FragColor = color; }\n";
// Attribute 0 to match kVertexSrc's single "position" attribute - fine to static void vec3_cross(const float a[3], const float b[3], float out[3]) {
// reuse the same numeric index the engine's own immediate-mode drawing out[0] = a[1] * b[2] - a[2] * b[1];
// treats specially, since that's a per-program binding and this program is out[1] = a[2] * b[0] - a[0] * b[2];
// never current at the same time as gl4es's immediate-mode emulation runs; out[2] = a[0] * b[1] - a[1] * b[0];
// xr_input_sync_and_draw() disables the array again right after drawing, }
// the same discipline android_draw_surface_as_quad() already established.
#define RETICLE_POSITION_LOC 0
static bool xr_input_init_reticle_program(void) { // Returns false (out left untouched) if v is too close to zero-length to
GLuint vs = compile_shader(GL_VERTEX_SHADER, kVertexSrc); // normalize safely - callers use this to detect a degenerate billboard
GLuint fs = compile_shader(GL_FRAGMENT_SHADER, kFragmentSrc); // axis and fall back to another reference vector.
g_reticle_program = glCreateProgram(); static bool vec3_normalize(const float v[3], float out[3]) {
glAttachShader(g_reticle_program, vs); float len = sqrtf(vec3_dot(v, v));
glAttachShader(g_reticle_program, fs); if (len < 1e-6f)
glBindAttribLocation(g_reticle_program, RETICLE_POSITION_LOC, "position");
glLinkProgram(g_reticle_program);
GLint linked = GL_FALSE;
glGetProgramiv(g_reticle_program, GL_LINK_STATUS, &linked);
glDeleteShader(vs);
glDeleteShader(fs);
if (!linked) {
char log[512];
glGetProgramInfoLog(g_reticle_program, sizeof(log), NULL, log);
LOGE("XR: reticle program link failed: %s", log);
return false; return false;
} out[0] = v[0] / len;
g_reticle_color_loc = glGetUniformLocation(g_reticle_program, "color"); out[1] = v[1] / len;
out[2] = v[2] / len;
return true; return true;
} }
bool xr_input_init(XrInstance instance, XrSession session) { // Converts an orthonormal local->world rotation matrix (given as its
// columns - x/y/z, each the world-space direction of that local axis) to
// an XrQuaternionf, via the standard trace-based (Shepperd) method.
static void mat3_to_quat(const float x[3], const float y[3], const float z[3], XrQuaternionf *q) {
float m00 = x[0], m10 = x[1], m20 = x[2];
float m01 = y[0], m11 = y[1], m21 = y[2];
float m02 = z[0], m12 = z[1], m22 = z[2];
float trace = m00 + m11 + m22;
if (trace > 0.0f) {
float s = sqrtf(trace + 1.0f) * 2.0f;
q->w = 0.25f * s;
q->x = (m21 - m12) / s;
q->y = (m02 - m20) / s;
q->z = (m10 - m01) / s;
} else if (m00 > m11 && m00 > m22) {
float s = sqrtf(1.0f + m00 - m11 - m22) * 2.0f;
q->w = (m21 - m12) / s;
q->x = 0.25f * s;
q->y = (m01 + m10) / s;
q->z = (m02 + m20) / s;
} else if (m11 > m22) {
float s = sqrtf(1.0f + m11 - m00 - m22) * 2.0f;
q->w = (m02 - m20) / s;
q->x = (m01 + m10) / s;
q->y = 0.25f * s;
q->z = (m12 + m21) / s;
} else {
float s = sqrtf(1.0f + m22 - m00 - m11) * 2.0f;
q->w = (m10 - m01) / s;
q->x = (m02 + m20) / s;
q->y = (m12 + m21) / s;
q->z = 0.25f * s;
}
}
bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_format) {
g_instance = instance; g_instance = instance;
g_session = session; g_session = session;
@@ -213,13 +276,296 @@ bool xr_input_init(XrInstance instance, XrSession session) {
if (!xr_check(xrAttachSessionActionSets(session, &attachInfo), "xrAttachSessionActionSets")) if (!xr_check(xrAttachSessionActionSets(session, &attachInfo), "xrAttachSessionActionSets"))
return false; return false;
if (!xr_input_init_reticle_program()) XrReferenceSpaceCreateInfo viewSpaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
viewSpaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW;
viewSpaceInfo.poseInReferenceSpace.orientation.w = 1.0f;
if (!xr_check(xrCreateReferenceSpace(session, &viewSpaceInfo, &g_view_space),
"xrCreateReferenceSpace(view)"))
return false; return false;
for (int hand = 0; hand < 2; hand++) {
if (!xr_swapchain_create(instance, session, swapchain_format, BEAM_TEX_SIZE, BEAM_TEX_SIZE,
&g_beam_swapchain[hand])) {
LOGE("XR: beam swapchain setup failed for hand %d", hand);
return false;
}
}
LOGI("XR: input action set ready (aim pose + trigger + menu-toggle)"); LOGI("XR: input action set ready (aim pose + trigger + menu-toggle)");
return true; return true;
} }
// Hit-tests/dispatches this hand's ray against one overlay quad - shared
// by the menu launcher and keyboard overlays in xr_input_sync_and_draw()
// below. dragCapable enables the keyboard-only title-bar/drag-handle
// handling (see TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION); the menu
// launcher has no title bar, so it's always false there and every hit is a
// plain click. Returns true only when this hand's ray is actually
// relevant to this overlay this frame - landing on it now, or continuing
// a touch/drag begun on a previous frame for this hand that hasn't been
// released yet - not merely because the overlay is visible. The caller
// should only stop trying other targets (menu/keyboard/game quad) when
// this returns true; a visible-but-unclaimed overlay falls through so a
// lower-priority target (ultimately the game quad) can still be aimed at.
static bool xr_input_try_overlay(XrOverlay *overlay, bool dragCapable, int hand,
const XrSpaceLocation *location, float fx, float fy, float fz,
bool selectDownEdge, bool selectUpEdge, bool *touchActive,
bool *prevHit, const char *quadName, float *outCursorU,
float *outCursorV, bool *outCursorHit, bool *outPlaneHit,
float *outPlaneDistance) {
*outPlaneHit = false;
if (!xr_overlay_is_visible(overlay))
return false;
// Captured before this call's own down/up-edge handling below can
// mutate them, so a hand with a pending down-touch (waiting for its
// matching up) or an in-progress drag on *this* overlay still claims
// the ray this frame even if it has strayed off the panel's current
// bounds - see `claims` below.
bool hadTouch = *touchActive;
bool hadDrag = dragCapable && g_keyboard_dragging && g_keyboard_drag_hand == hand;
float quadCenterX, quadCenterY, distance, halfWidth, halfHeight;
xr_overlay_get_quad_extent(overlay, &quadCenterX, &quadCenterY, &distance, &halfWidth,
&halfHeight);
// The quad's plane is z = -distance (facing the viewer along -Z) - a
// ray parallel to it (fz ~ 0) never crosses. worldX/Y is the raw
// world-space intersection point, needed as-is for dragging (which can
// legitimately move the quad to where the ray isn't currently hitting
// it); cx/cy are that same point in the quad's own [-1,1] local space;
// hit/u/v (the equivalent 0..1, top-left-origin coordinates the Java
// view's pixel grid and the on-quad hit logs use) are only meaningful
// within the quad's current bounds, unlike planeHit.
bool planeHit = false, hit = false;
float u = 0.0f, v = 0.0f, worldX = 0.0f, worldY = 0.0f, t = 0.0f;
if (fabsf(fz) > 1e-5f) {
t = (-distance - location->pose.position.z) / fz;
if (t > 0.0f) {
planeHit = true;
worldX = location->pose.position.x + t * fx;
worldY = location->pose.position.y + t * fy;
float cx = (worldX - quadCenterX) / halfWidth;
float cy = (worldY - quadCenterY) / halfHeight;
hit = fabsf(cx) <= 1.0f && fabsf(cy) <= 1.0f;
u = (cx + 1.0f) * 0.5f;
v = (1.0f - cy) * 0.5f;
}
}
*outPlaneHit = planeHit;
*outPlaneDistance = t;
if (hit != *prevHit) {
LOGI("XR: %s aim ray %s %s quad (u=%.2f v=%.2f)", kHandName[hand],
hit ? "entered" : "left", quadName, u, v);
*prevHit = hit;
}
if (hit) {
*outCursorHit = true;
*outCursorU = u;
*outCursorV = v;
}
// Only claim (and thus route/beam) this hand's ray to this overlay
// when it's actually relevant this frame: landing on it now, or
// continuing a touch/drag that started on it - never merely because
// it's visible.
bool claims = hit || hadTouch || hadDrag;
if (!dragCapable) {
if (selectDownEdge && hit) {
xr_overlay_touch(overlay, u, v, true);
*touchActive = true;
} else if (selectUpEdge && *touchActive) {
xr_overlay_touch(overlay, u, v, false);
*touchActive = false;
}
return claims;
}
bool onTitleBar = hit && v < TITLE_BAR_V_FRACTION;
bool onDragHandle = onTitleBar && u <= CLOSE_BUTTON_U_FRACTION;
if (selectDownEdge) {
if (onDragHandle) {
g_keyboard_dragging = true;
g_keyboard_drag_hand = hand;
g_keyboard_drag_offset_x = worldX - quadCenterX;
g_keyboard_drag_offset_y = worldY - quadCenterY;
} else if (hit) {
xr_overlay_touch(overlay, u, v, true);
*touchActive = true;
}
} else if (selectUpEdge) {
if (g_keyboard_dragging && g_keyboard_drag_hand == hand) {
g_keyboard_dragging = false;
g_keyboard_drag_hand = -1;
} else if (*touchActive) {
xr_overlay_touch(overlay, u, v, false);
*touchActive = false;
}
}
// Re-intersects against the same fixed-distance plane every frame the
// drag continues, so the quad tracks the hand in real time rather than
// only jumping on the down/up edges above.
if (g_keyboard_dragging && g_keyboard_drag_hand == hand && planeHit) {
xr_overlay_set_position(overlay, worldX - g_keyboard_drag_offset_x,
worldY - g_keyboard_drag_offset_y);
}
return claims;
}
// Builds this hand's laser-beam quad - a thin, billboarded ribbon from the
// controller (location->pose.position) along the aim ray (fx,fy,fz,
// already unit length) to length_m meters out, oriented so it reads as a
// line from the viewer's eye (headLoc->pose.position) regardless of angle:
// one in-plane axis follows the ray direction, the other is the thin
// "width", and the quad's normal is billboarded toward the eye
// (orthogonalized against the ray axis, so the ribbon only rotates around
// its own long axis as the hand moves, never twists). Acquires/clears/
// releases this hand's tiny beam swapchain and leaves g_beam_quad[hand]
// ready for xr_input_get_beam_layer() - doesn't set space/eyeVisibility/
// layerFlags (see that function's doc comment in xr_input.h).
static void xr_input_build_beam(int hand, const XrSpaceLocation *location, float fx, float fy,
float fz, float length_m, const XrSpaceLocation *headLoc) {
float origin[3] = {location->pose.position.x, location->pose.position.y,
location->pose.position.z};
float dirY[3] = {fx, fy, fz};
float endpoint[3] = {origin[0] + length_m * fx, origin[1] + length_m * fy,
origin[2] + length_m * fz};
float center[3] = {(origin[0] + endpoint[0]) * 0.5f, (origin[1] + endpoint[1]) * 0.5f,
(origin[2] + endpoint[2]) * 0.5f};
float head[3] = {headLoc->pose.position.x, headLoc->pose.position.y,
headLoc->pose.position.z};
float toEyeRaw[3], toEye[3];
vec3_sub(head, center, toEyeRaw);
if (!vec3_normalize(toEyeRaw, toEye)) {
// Head is (almost) exactly at the beam's midpoint - astronomically
// unlikely, but fall back to a fixed direction rather than divide
// by ~0.
toEye[0] = 0.0f;
toEye[1] = 0.0f;
toEye[2] = 1.0f;
}
// Orthogonalize toEye against the ray axis to get the billboard
// normal - if the ray points almost straight at/away from the eye,
// that leaves ~nothing to normalize, so fall back to world-up then
// world-Z, each reprojected the same way.
float normalZ[3];
float d = vec3_dot(toEye, dirY);
float proj[3] = {toEye[0] - dirY[0] * d, toEye[1] - dirY[1] * d, toEye[2] - dirY[2] * d};
if (!vec3_normalize(proj, normalZ)) {
static const float kWorldUp[3] = {0.0f, 1.0f, 0.0f};
d = vec3_dot(kWorldUp, dirY);
proj[0] = kWorldUp[0] - dirY[0] * d;
proj[1] = kWorldUp[1] - dirY[1] * d;
proj[2] = kWorldUp[2] - dirY[2] * d;
if (!vec3_normalize(proj, normalZ)) {
static const float kWorldZ[3] = {0.0f, 0.0f, 1.0f};
d = vec3_dot(kWorldZ, dirY);
proj[0] = kWorldZ[0] - dirY[0] * d;
proj[1] = kWorldZ[1] - dirY[1] * d;
proj[2] = kWorldZ[2] - dirY[2] * d;
if (!vec3_normalize(proj, normalZ)) {
// dirY parallel to both world-up and world-Z is impossible
// for two non-parallel vectors - unreachable in practice,
// but keep the beam well-defined regardless.
normalZ[0] = 1.0f;
normalZ[1] = 0.0f;
normalZ[2] = 0.0f;
}
}
}
float axisX[3];
vec3_cross(dirY, normalZ, axisX);
if (!vec3_normalize(axisX, axisX)) {
axisX[0] = 1.0f;
axisX[1] = 0.0f;
axisX[2] = 0.0f;
}
float axisZ[3];
vec3_cross(axisX, dirY, axisZ); // already unit length - axisX/dirY are orthonormal
XrQuaternionf orientation;
mat3_to_quat(axisX, dirY, axisZ, &orientation);
if (xr_swapchain_acquire(g_instance, &g_beam_swapchain[hand])) {
// Premultiplied alpha (matches OverlayPanel.compositeAndPublish()'s
// convention, and XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT's
// assumption - see xr_session.c) - kHandColor's RGB scaled by this
// beam's own alpha, not the opaque RGB itself.
const float alpha = 0.55f;
const float *c = kHandColor[hand];
glClearColor(c[0] * alpha, c[1] * alpha, c[2] * alpha, alpha);
glClear(GL_COLOR_BUFFER_BIT);
xr_swapchain_release(g_instance, &g_beam_swapchain[hand]);
}
XrCompositionLayerQuad *quad = &g_beam_quad[hand];
quad->type = XR_TYPE_COMPOSITION_LAYER_QUAD;
quad->next = NULL;
quad->subImage.swapchain = g_beam_swapchain[hand].swapchain;
quad->subImage.imageRect.offset.x = 0;
quad->subImage.imageRect.offset.y = 0;
quad->subImage.imageRect.extent.width = BEAM_TEX_SIZE;
quad->subImage.imageRect.extent.height = BEAM_TEX_SIZE;
quad->subImage.imageArrayIndex = 0;
quad->pose.position.x = center[0];
quad->pose.position.y = center[1];
quad->pose.position.z = center[2];
quad->pose.orientation = orientation;
quad->size.width = BEAM_THICKNESS_METERS;
quad->size.height = length_m;
g_beam_quad_valid[hand] = true;
}
// Hit-tests this hand's ray against the game quad (xr_get_game_quad_extent())
// - the lowest-priority target, tried only once neither the keyboard nor
// menu overlay claimed the ray this frame (or, symmetrically, once this
// hand already has a pending mouse-down on the game quad - see
// g_game_touch_active and the keyboard/menu call sites in
// xr_input_sync_and_draw()). outU/outV (only meaningful when the
// returned hit is true) let the caller drive the game's own mouse cursor
// via xr_mouse.h. Returns plain `hit` - drag/touch dispatch for actual
// clicks is the caller's responsibility (xr_mouse_click()), not this
// function's, unlike xr_input_try_overlay().
static bool xr_input_try_game_quad(int hand, const XrSpaceLocation *location, float fx, float fy,
float fz, bool *outPlaneHit, float *outPlaneDistance,
float *outU, float *outV) {
float distance, halfWidth, halfHeight;
xr_get_game_quad_extent(&distance, &halfWidth, &halfHeight);
bool planeHit = false, hit = false;
float u = 0.0f, v = 0.0f, t = 0.0f;
if (fabsf(fz) > 1e-5f) {
t = (-distance - location->pose.position.z) / fz;
if (t > 0.0f) {
planeHit = true;
float cx = (location->pose.position.x + t * fx) / halfWidth;
float cy = (location->pose.position.y + t * fy) / halfHeight;
hit = fabsf(cx) <= 1.0f && fabsf(cy) <= 1.0f;
u = (cx + 1.0f) * 0.5f;
v = (1.0f - cy) * 0.5f;
}
}
*outPlaneHit = planeHit;
*outPlaneDistance = t;
*outU = u;
*outV = v;
if (hit != g_game_prev_hit[hand]) {
LOGI("XR: %s aim ray %s game quad (u=%.2f v=%.2f)", kHandName[hand],
hit ? "entered" : "left", u, v);
g_game_prev_hit[hand] = hit;
}
return hit;
}
void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) { void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
if (g_action_set == XR_NULL_HANDLE) if (g_action_set == XR_NULL_HANDLE)
return; return;
@@ -230,29 +576,47 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
syncInfo.activeActionSets = &activeSet; syncInfo.activeActionSets = &activeSet;
xr_check(xrSyncActions(g_session, &syncInfo), "xrSyncActions"); xr_check(xrSyncActions(g_session, &syncInfo), "xrSyncActions");
bool menuVisible = xr_menu_is_visible(); XrOverlay *menuOverlay = xr_session_get_menu_overlay();
XrOverlay *keyboardOverlay = xr_session_get_keyboard_overlay();
bool menuVisible = xr_overlay_is_visible(menuOverlay);
bool keyboardVisible = xr_overlay_is_visible(keyboardOverlay);
float quadCenterX = 0.0f, quadCenterY = 0.0f, distance, halfWidth, halfHeight; // A drag can't get stuck active across a hide/reopen - e.g. Close
if (menuVisible) // being hit on one hand mid-drag on the other, or the session ending.
xr_menu_get_quad_extent(&quadCenterX, &quadCenterY, &distance, &halfWidth, &halfHeight); if (!keyboardVisible) {
else g_keyboard_dragging = false;
xr_get_game_quad_extent(&distance, &halfWidth, &halfHeight); g_keyboard_drag_hand = -1;
}
static const char *kHandName[2] = {"left", "right"}; // A hand only ever gets a laser-beam quad this frame if it actually
static const float kHandColor[2][4] = { // claims the keyboard or menu overlay's ray below (see the
{0.2f, 1.0f, 1.0f, 1.0f}, // left: cyan // xr_input_build_beam() calls) - reset here so a hand that doesn't
{1.0f, 0.85f, 0.1f, 1.0f}, // right: amber // claim one this frame doesn't keep showing last frame's beam.
}; g_beam_quad_valid[LEFT] = g_beam_quad_valid[RIGHT] = false;
// The reticle is drawn directly into whatever framebuffer is currently // The billboard math needs an approximate head position - only bother
// bound - the game quad's swapchain image (see xr_frame_end(), which // locating it on frames where a beam could possibly be drawn at all
// calls this while that image is still bound). That only makes sense // (i.e. any drawn frame - the game quad can claim a beam on its own
// while aiming at the game quad; the menu quad's content comes from // even with both overlays closed).
// MenuOverlay's rendered Bitmap instead (xr_menu.c), uploaded on a bool needBeams = draw;
// separate swapchain, so no reticle is drawn for it here. XrSpaceLocation headLoc = {XR_TYPE_SPACE_LOCATION};
bool drawReticle = draw && !menuVisible; bool haveHead = false;
if (drawReticle) if (needBeams) {
glUseProgram(g_reticle_program); const XrSpaceLocationFlags neededHead =
XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
haveHead = xr_check(xrLocateSpace(g_view_space, baseSpace, time, &headLoc),
"xrLocateSpace(view)") &&
(headLoc.locationFlags & neededHead) == neededHead;
}
// Fed to xr_overlay_update_cursor() after the loop below - whichever
// hand's ray hits a given overlay last wins if both do, good enough
// for a single on-quad cursor per overlay (only ever updated when hit
// is true, so a later hand that misses doesn't hide an earlier hand's
// hit).
bool menuCursorHit = false, keyboardCursorHit = false;
float menuCursorU = 0.0f, menuCursorV = 0.0f;
float keyboardCursorU = 0.0f, keyboardCursorV = 0.0f;
for (int hand = 0; hand < 2; hand++) { for (int hand = 0; hand < 2; hand++) {
XrActionStateGetInfo selectInfo = {XR_TYPE_ACTION_STATE_GET_INFO}; XrActionStateGetInfo selectInfo = {XR_TYPE_ACTION_STATE_GET_INFO};
@@ -277,13 +641,14 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
bool menuDown = menuState.isActive && menuState.currentState; bool menuDown = menuState.isActive && menuState.currentState;
if (menuDown && !g_prev_menu) { if (menuDown && !g_prev_menu) {
LOGI("XR: menu-toggle button DOWN"); LOGI("XR: menu-toggle button DOWN");
xr_menu_toggle_visible(); // Only ever affects the menu launcher - the keyboard is
// Avoid a stale hit/touch-active carrying over from // fully independent (its own Close button is the only way
// whichever quad was being tested against before the // to hide it, see KeyboardOverlay.java).
// toggle - the next frame re-evaluates against the new if (menuOverlay != NULL) {
// target from a clean state. xr_overlay_toggle_visible(menuOverlay);
g_prev_hit[LEFT] = g_prev_hit[RIGHT] = false; g_menu_prev_hit[LEFT] = g_menu_prev_hit[RIGHT] = false;
g_menu_touch_active[LEFT] = g_menu_touch_active[RIGHT] = false; g_menu_touch_active[LEFT] = g_menu_touch_active[RIGHT] = false;
}
} else if (!menuDown && g_prev_menu) { } else if (!menuDown && g_prev_menu) {
LOGI("XR: menu-toggle button UP"); LOGI("XR: menu-toggle button UP");
} }
@@ -309,61 +674,96 @@ void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw) {
float fx, fy, fz; float fx, fy, fz;
quat_rotate_vec(&location.pose.orientation, 0.0f, 0.0f, -1.0f, &fx, &fy, &fz); quat_rotate_vec(&location.pose.orientation, 0.0f, 0.0f, -1.0f, &fx, &fy, &fz);
// The quad's plane is z = -distance (facing the viewer along -Z, // Keyboard first (it's the more likely target while it's up), then
// see QUAD_DISTANCE_METERS/xr_menu.c's MENU_DISTANCE_METERS) - a // the menu launcher, then - only once neither actually claims the
// ray parallel to it (fz ~ 0) never crosses. cx/cy are the hit // ray this frame (not merely "isn't visible" - see
// point in the quad's own [-1,1] local space (the same space // xr_input_try_overlay()'s doc comment) - the game quad itself.
// android_draw_surface_as_quad() draws its own vertex positions // Whichever target claims the ray gets a laser-beam quad built for
// in); u/v are the equivalent 0..1, top-left-origin coordinates // it (if a head pose is available and the ray actually crosses
// MenuOverlay's pixel grid and the on-quad hit logs both use. // that target's plane) - see xr_input_build_beam(). Keyboard/menu
bool hit = false; // are additionally gated on !g_game_touch_active[hand]: a pending
float u = 0.0f, v = 0.0f, cx = 0.0f, cy = 0.0f; // mouse-down on the game quad (trigger still held since a
if (fabsf(fz) > 1e-5f) { // down-edge dispatched there) must keep claiming the ray even if
float t = (-distance - location.pose.position.z) / fz; // it strays onto another panel before release, the cross-target
if (t > 0.0f) { // analogue of xr_input_try_overlay()'s own hadTouch/hadDrag - the
cx = (location.pose.position.x + t * fx - quadCenterX) / halfWidth; // eventual mouse-up has to reach the game quad, not whatever the
cy = (location.pose.position.y + t * fy - quadCenterY) / halfHeight; // ray happens to be over on the release frame.
hit = fabsf(cx) <= 1.0f && fabsf(cy) <= 1.0f; bool keyboardPlaneHit = false;
u = (cx + 1.0f) * 0.5f; float keyboardPlaneDistance = 0.0f;
v = (1.0f - cy) * 0.5f; if (!g_game_touch_active[hand] &&
} xr_input_try_overlay(keyboardOverlay, true, hand, &location, fx, fy, fz,
} selectDownEdge, selectUpEdge, &g_keyboard_touch_active[hand],
&g_keyboard_prev_hit[hand], "keyboard", &keyboardCursorU,
if (menuVisible) { &keyboardCursorV, &keyboardCursorHit, &keyboardPlaneHit,
if (hit != g_prev_hit[hand]) { &keyboardPlaneDistance)) {
LOGI("XR: %s aim ray %s menu quad (u=%.2f v=%.2f)", kHandName[hand], if (haveHead && keyboardPlaneHit)
hit ? "entered" : "left", u, v); xr_input_build_beam(hand, &location, fx, fy, fz,
g_prev_hit[hand] = hit; fminf(keyboardPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc);
} continue;
if (selectDownEdge && hit) {
xr_menu_touch(u, v, true);
g_menu_touch_active[hand] = true;
} else if (selectUpEdge && g_menu_touch_active[hand]) {
xr_menu_touch(u, v, false);
g_menu_touch_active[hand] = false;
} }
bool menuPlaneHit = false;
float menuPlaneDistance = 0.0f;
if (!g_game_touch_active[hand] &&
xr_input_try_overlay(menuOverlay, false, hand, &location, fx, fy, fz, selectDownEdge,
selectUpEdge, &g_menu_touch_active[hand], &g_menu_prev_hit[hand],
"menu", &menuCursorU, &menuCursorV, &menuCursorHit, &menuPlaneHit,
&menuPlaneDistance)) {
if (haveHead && menuPlaneHit)
xr_input_build_beam(hand, &location, fx, fy, fz,
fminf(menuPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc);
continue; continue;
} }
if (hit != g_prev_hit[hand]) { // Lowest priority (for a fresh hit): the game quad itself - drives
LOGI("XR: %s aim ray %s game quad (u=%.2f v=%.2f)", kHandName[hand], // the engine's own mouse cursor/clicks via xr_mouse.h, exactly
hit ? "entered" : "left", u, v); // like a desktop mouse (absolute position + left button), plus the
g_prev_hit[hand] = hit; // same laser-beam visual the other two targets get.
bool gamePlaneHit = false;
float gamePlaneDistance = 0.0f, gameU = 0.0f, gameV = 0.0f;
bool gameHit = xr_input_try_game_quad(hand, &location, fx, fy, fz, &gamePlaneHit,
&gamePlaneDistance, &gameU, &gameV);
if (gameHit) {
int gameWidth, gameHeight;
xr_get_game_resolution(&gameWidth, &gameHeight);
int px = (int)(gameU * (float)gameWidth);
int py = (int)(gameV * (float)gameHeight);
if (px < 0)
px = 0;
else if (px >= gameWidth)
px = gameWidth - 1;
if (py < 0)
py = 0;
else if (py >= gameHeight)
py = gameHeight - 1;
xr_mouse_move(px, py);
}
if (selectDownEdge && gameHit) {
xr_mouse_click(true);
g_game_touch_active[hand] = true;
} else if (selectUpEdge && g_game_touch_active[hand]) {
xr_mouse_click(false);
g_game_touch_active[hand] = false;
}
// gameHit implies gamePlaneHit (both only ever set together above),
// kept as a separate out-param for symmetry with
// xr_input_try_overlay()'s outPlaneHit/outPlaneDistance pair.
if (haveHead && gameHit)
xr_input_build_beam(hand, &location, fx, fy, fz,
fminf(gamePlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc);
} }
if (!hit || !drawReticle) if (menuVisible)
continue; xr_overlay_update_cursor(menuOverlay, menuCursorU, menuCursorV, menuCursorHit);
if (keyboardVisible)
const float kSize = 0.03f; xr_overlay_update_cursor(keyboardOverlay, keyboardCursorU, keyboardCursorV,
const float verts[] = { keyboardCursorHit);
cx - kSize, cy, 0.0f, cx + kSize, cy, 0.0f, cx, cy - kSize, 0.0f, cx, cy + kSize, 0.0f,
};
glUniform4fv(g_reticle_color_loc, 1, kHandColor[hand]);
glEnableVertexAttribArray(RETICLE_POSITION_LOC);
glVertexAttribPointer(RETICLE_POSITION_LOC, 3, GL_FLOAT, GL_FALSE, 0, verts);
glDrawArrays(GL_LINES, 0, 4);
glDisableVertexAttribArray(RETICLE_POSITION_LOC);
} }
bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad) {
if (hand < 0 || hand > 1 || !g_beam_quad_valid[hand])
return false;
*out_quad = g_beam_quad[hand];
return true;
} }
void xr_input_shutdown(void) { void xr_input_shutdown(void) {
@@ -371,7 +771,11 @@ void xr_input_shutdown(void) {
if (g_aim_space[hand] != XR_NULL_HANDLE) if (g_aim_space[hand] != XR_NULL_HANDLE)
xrDestroySpace(g_aim_space[hand]); xrDestroySpace(g_aim_space[hand]);
g_aim_space[hand] = XR_NULL_HANDLE; g_aim_space[hand] = XR_NULL_HANDLE;
xr_swapchain_destroy(&g_beam_swapchain[hand]);
} }
if (g_view_space != XR_NULL_HANDLE)
xrDestroySpace(g_view_space);
g_view_space = XR_NULL_HANDLE;
if (g_action_set != XR_NULL_HANDLE) if (g_action_set != XR_NULL_HANDLE)
xrDestroyActionSet(g_action_set); xrDestroyActionSet(g_action_set);
g_action_set = XR_NULL_HANDLE; g_action_set = XR_NULL_HANDLE;
@@ -379,14 +783,17 @@ void xr_input_shutdown(void) {
g_select_click_action = XR_NULL_HANDLE; g_select_click_action = XR_NULL_HANDLE;
g_menu_toggle_action = XR_NULL_HANDLE; g_menu_toggle_action = XR_NULL_HANDLE;
if (g_reticle_program != 0)
glDeleteProgram(g_reticle_program);
g_reticle_program = 0;
g_instance = XR_NULL_HANDLE; g_instance = XR_NULL_HANDLE;
g_session = XR_NULL_HANDLE; g_session = XR_NULL_HANDLE;
memset(g_prev_select, 0, sizeof(g_prev_select)); memset(g_prev_select, 0, sizeof(g_prev_select));
memset(g_prev_hit, 0, sizeof(g_prev_hit)); memset(g_game_prev_hit, 0, sizeof(g_game_prev_hit));
memset(g_game_touch_active, 0, sizeof(g_game_touch_active));
memset(g_menu_prev_hit, 0, sizeof(g_menu_prev_hit));
memset(g_menu_touch_active, 0, sizeof(g_menu_touch_active)); memset(g_menu_touch_active, 0, sizeof(g_menu_touch_active));
memset(g_keyboard_prev_hit, 0, sizeof(g_keyboard_prev_hit));
memset(g_keyboard_touch_active, 0, sizeof(g_keyboard_touch_active));
g_beam_quad_valid[LEFT] = g_beam_quad_valid[RIGHT] = false;
g_prev_menu = false; g_prev_menu = false;
g_keyboard_dragging = false;
g_keyboard_drag_hand = -1;
} }
+43 -15
View File
@@ -1,15 +1,30 @@
// Controller input for questshock's immersive Quest build: one OpenXR // Controller input for questshock's immersive Quest build: one OpenXR
// action set (aim pose + trigger click per hand, a menu-toggle button on // action set (aim pose + trigger click per hand, a menu-toggle button on
// the left controller) plus ray/quad hit-testing. While the menu quad // the left controller) plus ray/quad hit-testing. Each hand's ray is
// (xr_menu.c) is hidden, this tests against the game quad xr_session.c // tried against the keyboard overlay, then the menu overlay (see
// submits and draws a small reticle where each hand's aim ray crosses it; // xr_overlay.h, xr_session.c), then - only if neither actually claims it
// while the menu is visible, it tests against the menu quad instead and // this frame (landing on it now, or continuing a touch/drag begun on a
// forwards trigger edges as synthetic touches (no reticle - the menu's // previous frame - not merely because that overlay happens to be visible)
// own content comes from MenuOverlay's rendered Bitmap). // - the game quad xr_session.c submits, forwarding trigger edges to
// whichever overlay claims the ray as synthetic touches. The game quad
// itself acts as a plain desktop-style mouse pointer into the engine's
// own UI (see xr_mouse.h) - absolute cursor position while the ray hits
// it, plus a left click on the trigger edge; a pending click there
// likewise keeps claiming the ray ahead of the keyboard/menu until
// released, so the eventual mouse-up isn't lost if the ray strays. Since
// none of the three targets' own feedback (the overlays' on-quad cursor,
// drawn by their Java views; the engine's own mouse cursor sprite for the
// game quad) gives any indication while the ray is short of actually
// landing on something, every claimed target also builds a thin,
// billboarded laser-beam quad per hand from the controller to wherever
// the ray currently crosses that target's plane (see
// xr_input_get_beam_layer()) - so aiming looks and behaves the same
// whether the target is an overlay or the game quad itself.
#ifndef QUESTSHOCK_XR_INPUT_H #ifndef QUESTSHOCK_XR_INPUT_H
#define QUESTSHOCK_XR_INPUT_H #define QUESTSHOCK_XR_INPUT_H
#include <stdbool.h> #include <stdbool.h>
#include <stdint.h>
#define XR_USE_PLATFORM_ANDROID 1 #define XR_USE_PLATFORM_ANDROID 1
#define XR_USE_GRAPHICS_API_OPENGL_ES 1 #define XR_USE_GRAPHICS_API_OPENGL_ES 1
@@ -22,22 +37,35 @@ extern "C" {
// Call once, right after the session is created (see xr_session.c's // Call once, right after the session is created (see xr_session.c's
// xr_create_instance_and_session()) - creates the action set/actions, // xr_create_instance_and_session()) - creates the action set/actions,
// suggests Touch controller bindings, creates the per-hand aim action // suggests Touch controller bindings, creates the per-hand aim action
// spaces, and attaches the set to the session. Returns false (logged, // spaces and a view-space reference (for the laser-beam billboard math),
// non-fatal - the caller keeps rendering without input) if any of that // creates the two per-hand beam swapchains (swapchain_format - the same
// fails. // format shared by the game swapchain and both overlays), and attaches
bool xr_input_init(XrInstance instance, XrSession session); // the action set to the session. Returns false (logged, non-fatal - the
// caller keeps rendering without input) if any of that fails.
bool xr_input_init(XrInstance instance, XrSession session, int64_t swapchain_format);
// Call once per frame from xr_frame_end(), before releasing the acquired // Call once per frame from xr_frame_end(), before releasing the acquired
// swapchain image - syncs this frame's action states (always, so edge // swapchain image - syncs this frame's action states (always, so edge
// detection stays correct even on frames with nothing to draw), handles // detection stays correct even on frames with nothing to draw), handles
// the menu_toggle button's edge, and either draws a game-quad reticle or // the menu_toggle button's edge, and hit-tests/dispatches each hand's ray
// forwards menu-quad touches, per the menu's current visibility (see the // against keyboard/menu/game quad in that priority order (see the file
// file comment above). draw gates only the reticle - if false (nothing to // comment above). draw gates only the laser-beam visuals (and the
// draw into this frame, e.g. no swapchain image was acquired), hit-testing // head-pose locate that feeds them) - if false (nothing to draw into this
// and touch-forwarding still run. baseSpace/time must match whatever // frame, e.g. no swapchain image was acquired), hit-testing and
// touch-forwarding still run. baseSpace/time must match whatever
// xr_frame_begin() used to predict this frame. // xr_frame_begin() used to predict this frame.
void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw); void xr_input_sync_and_draw(XrSpace baseSpace, XrTime time, bool draw);
// Call once per hand (hand: 0=left, 1=right) after xr_input_sync_and_draw()
// in the same frame - if that hand's ray claimed the keyboard, menu, or
// game quad this frame (see the file comment above), fills *out_quad's
// subImage/pose/size for its laser-beam ribbon and returns true.
// space/eyeVisibility/layerFlags are left for the caller to set, same
// convention as xr_overlay_render_and_build_layer(). Returns false
// (out_quad untouched) if no beam should be shown for that hand this
// frame.
bool xr_input_get_beam_layer(int hand, XrCompositionLayerQuad *out_quad);
void xr_input_shutdown(void); void xr_input_shutdown(void);
#ifdef __cplusplus #ifdef __cplusplus
-356
View File
@@ -1,356 +0,0 @@
#include "xr_menu.h"
#include <dlfcn.h>
#include <stdlib.h>
#include <android/log.h>
#include <jni.h>
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#include <SDL.h>
#define TAG "QuestShock"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define MENU_WIDTH 1024
#define MENU_HEIGHT 768
// Below the game quad (QUAD_DISTANCE_METERS/QUAD_WIDTH_METERS in
// xr_session.c, 2m out) so both are visible together without overlapping.
#define MENU_DISTANCE_METERS 1.5f
#define MENU_WIDTH_METERS 0.8f
#define MENU_CENTER_X_METERS 0.0f
#define MENU_CENTER_Y_METERS -0.45f
static bool g_visible = false;
static jclass g_menu_overlay_class = NULL;
static jmethodID g_native_init_method = NULL;
static jmethodID g_take_pixels_method = NULL;
static jmethodID g_dispatch_touch_method = NULL;
// The menu overlay's content lives in this texture, uploaded by
// xr_menu_upload_source_texture_if_dirty() below. It's created through
// gl4es (glGenTextures/glBindTexture/glTexImage2D), like any other texture
// the engine itself creates, so ordinary glTexSubImage2D uploads against it
// work the normal way.
static GLuint g_source_texture = 0;
// A framebuffer with g_source_texture as its only color attachment, used as
// the read source for xr_menu_render_if_visible()'s glBlitFramebuffer()
// call. Created via the real (non-gl4es) GLES entry points below: that blit
// bypasses gl4es entirely (see xr_menu_render_if_visible() for why), so its
// source framebuffer has to be a real GL object rather than one gl4es
// tracks.
static GLuint g_source_fbo = 0;
typedef void (*PFNQSGETINTEGERV)(GLenum, GLint *);
typedef void (*PFNQSGENFRAMEBUFFERS)(GLsizei, GLuint *);
typedef void (*PFNQSDELETEFRAMEBUFFERS)(GLsizei, const GLuint *);
typedef void (*PFNQSBINDFRAMEBUFFER)(GLenum, GLuint);
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
typedef void (*PFNQSBLITFRAMEBUFFER)(GLint, GLint, GLint, GLint, GLint, GLint, GLint, GLint,
GLbitfield, GLenum);
// The menu's source framebuffer and its glBlitFramebuffer() copy into the
// swapchain image go through the real GLES driver rather than gl4es: gl4es's
// fixed-pipeline emulation unconditionally substitutes its own shader onto
// any gl4es-routed draw call, which would silently replace the menu's
// content with whatever the game itself last rendered (see
// xr_menu_render_if_visible()). A framebuffer blit has no shader stage at
// all, so going through the real driver for it sidesteps the problem
// entirely.
static PFNQSGETINTEGERV real_glGetIntegerv;
static PFNQSGENFRAMEBUFFERS real_glGenFramebuffers;
static PFNQSDELETEFRAMEBUFFERS real_glDeleteFramebuffers;
static PFNQSBINDFRAMEBUFFER real_glBindFramebuffer;
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
// glBlitFramebuffer is a GLES 3.0 addition; resolved via eglGetProcAddress
// rather than dlsym, since Android's driver dispatch doesn't guarantee ES3+
// symbols are dlsym-able by name from libGLESv2.so, unlike the GLES2-core
// functions above.
static PFNQSBLITFRAMEBUFFER real_glBlitFramebuffer;
static bool xr_menu_load_real_gles(void) {
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
if (lib == NULL) {
LOGE("XR: menu dlopen(libGLESv2.so) failed: %s", dlerror());
return false;
}
real_glGetIntegerv = (PFNQSGETINTEGERV)dlsym(lib, "glGetIntegerv");
real_glGenFramebuffers = (PFNQSGENFRAMEBUFFERS)dlsym(lib, "glGenFramebuffers");
real_glDeleteFramebuffers = (PFNQSDELETEFRAMEBUFFERS)dlsym(lib, "glDeleteFramebuffers");
real_glBindFramebuffer = (PFNQSBINDFRAMEBUFFER)dlsym(lib, "glBindFramebuffer");
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
real_glBlitFramebuffer = (PFNQSBLITFRAMEBUFFER)eglGetProcAddress("glBlitFramebuffer");
return real_glGetIntegerv && real_glGenFramebuffers && real_glDeleteFramebuffers &&
real_glBindFramebuffer && real_glFramebufferTexture2D && real_glBlitFramebuffer;
}
static uint32_t g_pixel_generation = 0;
static uint32_t g_source_uploaded_generation = (uint32_t)-1;
static uint8_t *g_pixel_cache = NULL; // MENU_WIDTH*MENU_HEIGHT*4 bytes
void xr_menu_get_content_size(int *width, int *height) {
*width = MENU_WIDTH;
*height = MENU_HEIGHT;
}
// FindClass() from this thread (SDL's native thread, attached to the JVM
// via AttachCurrentThread rather than spawned from Java) resolves against
// the bootstrap classloader, which only knows framework classes - it can't
// see de.ladkau.questshock.MenuOverlay at all. Routing through the
// activity's own classloader is the standard, documented workaround.
static jclass xr_menu_find_class(JNIEnv *env, jobject activity, const char *name) {
jclass activityClass = (*env)->GetObjectClass(env, activity);
jmethodID getClassLoader =
(*env)->GetMethodID(env, activityClass, "getClassLoader", "()Ljava/lang/ClassLoader;");
jobject classLoader = (*env)->CallObjectMethod(env, activity, getClassLoader);
jclass classLoaderClass = (*env)->FindClass(env, "java/lang/ClassLoader");
jmethodID loadClass = (*env)->GetMethodID(env, classLoaderClass, "loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;");
jstring className = (*env)->NewStringUTF(env, name);
jclass result = (jclass)(*env)->CallObjectMethod(env, classLoader, loadClass, className);
(*env)->DeleteLocalRef(env, className);
return result;
}
static bool xr_menu_init_jni(void) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
jobject activity = (jobject)SDL_AndroidGetActivity();
if (env == NULL || activity == NULL) {
LOGE("XR: menu - no JNIEnv/Activity from SDL");
return false;
}
jclass localClass = xr_menu_find_class(env, activity, "de.ladkau.questshock.MenuOverlay");
if (localClass == NULL) {
LOGE("XR: menu - could not find MenuOverlay class");
return false;
}
g_menu_overlay_class = (jclass)(*env)->NewGlobalRef(env, localClass);
g_native_init_method =
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeInit", "(Landroid/app/Activity;)V");
g_take_pixels_method =
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeTakePixelsIfDirty", "()[B");
g_dispatch_touch_method =
(*env)->GetStaticMethodID(env, g_menu_overlay_class, "nativeDispatchTouch", "(FFZ)V");
if (!g_native_init_method || !g_take_pixels_method || !g_dispatch_touch_method) {
LOGE("XR: menu - could not resolve MenuOverlay JNI methods");
return false;
}
(*env)->CallStaticVoidMethod(env, g_menu_overlay_class, g_native_init_method, activity);
// CallStaticVoidMethod doesn't surface Java exceptions on its own - if
// MenuOverlay's constructor throws (it runs its View measure/layout/
// draw calls on this native render thread rather than the Android UI
// thread, a plausible crash vector), the exception would otherwise be
// left silently pending and corrupt whatever JNI call runs next.
if ((*env)->ExceptionCheck(env)) {
LOGE("XR: menu MenuOverlay.nativeInit() threw a pending Java exception:");
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
return false;
}
return true;
}
static bool xr_menu_create_source_texture(void) {
glGenTextures(1, &g_source_texture);
glBindTexture(GL_TEXTURE_2D, g_source_texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, MENU_WIDTH, MENU_HEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE,
NULL);
GLint prevFbo = 0;
real_glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo);
real_glGenFramebuffers(1, &g_source_fbo);
real_glBindFramebuffer(GL_FRAMEBUFFER, g_source_fbo);
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
g_source_texture, 0);
real_glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prevFbo);
return g_source_texture != 0;
}
bool xr_menu_init(XrInstance instance, XrSession session) {
(void)instance;
(void)session;
if (!xr_menu_load_real_gles()) {
LOGE("XR: menu couldn't resolve real GLES functions via dlsym/eglGetProcAddress");
return false;
}
if (!xr_menu_create_source_texture())
return false;
if (!xr_menu_init_jni())
return false;
g_pixel_cache = (uint8_t *)malloc((size_t)MENU_WIDTH * MENU_HEIGHT * 4);
if (g_pixel_cache == NULL)
return false;
LOGI("XR: menu overlay ready (%dx%d)", MENU_WIDTH, MENU_HEIGHT);
return true;
}
void xr_menu_toggle_visible(void) {
g_visible = !g_visible;
LOGI("XR: menu quad now %s", g_visible ? "visible" : "hidden");
}
bool xr_menu_is_visible(void) { return g_visible; }
void xr_menu_get_quad_extent(float *center_x_m, float *center_y_m, float *distance_m,
float *half_width_m, float *half_height_m) {
*center_x_m = MENU_CENTER_X_METERS;
*center_y_m = MENU_CENTER_Y_METERS;
*distance_m = MENU_DISTANCE_METERS;
*half_width_m = MENU_WIDTH_METERS * 0.5f;
*half_height_m = MENU_WIDTH_METERS * 0.5f * (float)MENU_HEIGHT / (float)MENU_WIDTH;
}
void xr_menu_touch(float u, float v, bool down) {
if (g_menu_overlay_class == NULL)
return;
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env == NULL)
return;
// Passed via CallStaticVoidMethodA/jvalue rather than the variadic
// Call*Method form - deliberately sidesteps relying on JNI
// implementations correctly un-doing C's float-to-double default
// argument promotion for varargs float parameters (a well-known JNI
// footgun; Android's ART handles it correctly, but there's no reason
// to depend on that when the jvalue form is unambiguous either way).
jvalue args[3];
args[0].f = u;
args[1].f = v;
args[2].z = (jboolean)down;
(*env)->CallStaticVoidMethodA(env, g_menu_overlay_class, g_dispatch_touch_method, args);
}
// Pulls MenuOverlay's latest pixels (if it redrew since the last check)
// into g_pixel_cache and bumps g_pixel_generation - called once per visible
// frame, before deciding whether g_source_texture needs a fresh upload.
static void xr_menu_refresh_pixel_cache(void) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env == NULL)
return;
jbyteArray pixels =
(jbyteArray)(*env)->CallStaticObjectMethod(env, g_menu_overlay_class, g_take_pixels_method);
// CallStaticObjectMethod also doesn't surface Java exceptions on its
// own - if nativeTakePixelsIfDirty() itself throws, it would otherwise
// silently look identical to "nothing changed yet" (a null return).
if ((*env)->ExceptionCheck(env)) {
LOGE("XR: menu nativeTakePixelsIfDirty() threw a pending Java exception:");
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
return;
}
if (pixels == NULL)
return;
(*env)->GetByteArrayRegion(env, pixels, 0, MENU_WIDTH * MENU_HEIGHT * 4, (jbyte *)g_pixel_cache);
(*env)->DeleteLocalRef(env, pixels);
g_pixel_generation++;
}
// Uploads g_pixel_cache into g_source_texture via plain (gl4es-routed)
// glBindTexture/glTexSubImage2D, matching how the texture was created.
// Skipped when nothing changed since the last upload. Texture-unit-0 state
// is saved and restored around the upload, since this call is gl4es-routed
// and the engine's own next-frame rendering assumes nothing touched its
// texture bindings since it last drew.
static void xr_menu_upload_source_texture_if_dirty(void) {
if (g_source_uploaded_generation == g_pixel_generation)
return;
GLint prevActiveTexture = GL_TEXTURE0;
glGetIntegerv(GL_ACTIVE_TEXTURE, &prevActiveTexture);
glActiveTexture(GL_TEXTURE0);
GLint prevTexture2D = 0;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &prevTexture2D);
glBindTexture(GL_TEXTURE_2D, g_source_texture);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, MENU_WIDTH, MENU_HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE,
g_pixel_cache);
g_source_uploaded_generation = g_pixel_generation;
glBindTexture(GL_TEXTURE_2D, (GLuint)prevTexture2D);
if (prevActiveTexture != GL_TEXTURE0)
glActiveTexture((GLenum)prevActiveTexture);
}
void xr_menu_render_if_visible(void) {
if (g_source_fbo == 0)
return;
xr_menu_refresh_pixel_cache();
xr_menu_upload_source_texture_if_dirty();
// Copies g_source_texture (via g_source_fbo) directly into the menu's
// sub-rectangle of the currently-bound shared swapchain framebuffer,
// using the real GLES3 hardware blit (glBlitFramebuffer) rather than a
// shader-based full-screen-quad draw. A blit has no vertex/fragment
// shading stage, no shader program, and no texture units involved at
// all, so gl4es's fixed-pipeline-emulation layer - which unconditionally
// substitutes a customized shader (reproducing the game's own
// last-bound texture/fixed-function state) onto any gl4es-routed draw
// call - has nothing to intercept here. It also never touches gl4es's
// own tracked program/vertex-array/texture-binding shadow state, so the
// only piece of state that needs save/restore is the READ framebuffer
// binding; binding only GL_READ_FRAMEBUFFER, rather than the combined
// GL_FRAMEBUFFER target, leaves the DRAW side (the swapchain image
// itself) untouched throughout.
//
// Y is flipped between source and destination: g_source_texture's texel
// row v=0 holds MenuOverlay's Bitmap row 0 (the top of the rendered
// content, standard top-row-first raster order), and reading that same
// texture via an attached FBO, framebuffer y=0 accesses that identical
// row. The destination swapchain framebuffer follows the opposite
// convention - its own y=0 is its bottom edge - so swapping dstY0/dstY1
// keeps the menu content right-side up (glBlitFramebuffer supports
// inverted src/dst rects for exactly this).
GLint prevReadFbo = 0;
real_glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prevReadFbo);
GLint viewport[4] = {0, 0, 0, 0};
real_glGetIntegerv(GL_VIEWPORT, viewport);
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, g_source_fbo);
real_glBlitFramebuffer(0, 0, MENU_WIDTH, MENU_HEIGHT, viewport[0], viewport[1] + viewport[3],
viewport[0] + viewport[2], viewport[1], GL_COLOR_BUFFER_BIT,
GL_LINEAR);
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)prevReadFbo);
}
void xr_menu_shutdown(void) {
if (g_source_texture != 0)
glDeleteTextures(1, &g_source_texture);
g_source_texture = 0;
g_source_uploaded_generation = (uint32_t)-1;
if (g_source_fbo != 0 && real_glDeleteFramebuffers != NULL)
real_glDeleteFramebuffers(1, &g_source_fbo);
g_source_fbo = 0;
if (g_menu_overlay_class != NULL) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env != NULL)
(*env)->DeleteGlobalRef(env, g_menu_overlay_class);
}
g_menu_overlay_class = NULL;
g_native_init_method = NULL;
g_take_pixels_method = NULL;
g_dispatch_touch_method = NULL;
free(g_pixel_cache);
g_pixel_cache = NULL;
g_pixel_generation = 0;
g_visible = false;
}
-70
View File
@@ -1,70 +0,0 @@
// Menu quad for questshock's immersive Quest build: MenuOverlay.java's
// off-screen-rendered View tree (currently just a "Keyboard" button - see
// the plan's step D for the on-screen keyboard that goes behind it),
// toggled by xr_input.c's menu_toggle action and hit-tested by the same ray
// xr_input.c already computes per hand.
//
// The menu's content shares xr_session.c's single OpenXR swapchain (see
// that file for why: two independently-created swapchains submitted as
// separate composition layers make the Horizon OS compositor show the
// game's own content on the menu quad instead of the menu's - a
// compositor-level limitation, not anything under this app's control).
// xr_session.c sizes its shared swapchain to fit both the game's and the
// menu's content side by side, and calls into this file to render the
// menu's own sub-rectangle each visible frame.
#ifndef QUESTSHOCK_XR_MENU_H
#define QUESTSHOCK_XR_MENU_H
#include <stdbool.h>
#define XR_USE_PLATFORM_ANDROID 1
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
#include <openxr/openxr.h>
#ifdef __cplusplus
extern "C" {
#endif
// Pixel size of the menu's own content region - xr_session.c needs this
// before it creates its shared swapchain, to size it to fit both the
// game's and the menu's content. Has no OpenXR/GL dependency, safe to call
// before xr_menu_init().
void xr_menu_get_content_size(int *width, int *height);
// Call once, after xr_session.c has created its shared swapchain - sets up
// the MenuOverlay Java-side singleton and the gl4es-owned texture its
// pixels get uploaded into. Returns false (logged, non-fatal - the caller
// just has no menu) on failure.
bool xr_menu_init(XrInstance instance, XrSession session);
// Flips menu-quad visibility - called by xr_input.c on the menu_toggle
// action's rising edge.
void xr_menu_toggle_visible(void);
bool xr_menu_is_visible(void);
// Local-space pose/size of the menu quad, valid regardless of visibility -
// shared with xr_input.c's ray/quad hit-testing, the same way
// xr_get_game_quad_extent() (xr_session.h) is for the game quad.
void xr_menu_get_quad_extent(float *center_x_m, float *center_y_m, float *distance_m,
float *half_width_m, float *half_height_m);
// Forwards a hit on the menu quad (in the quad's own 0..1 u/v, top-left
// origin) to the MenuOverlay Java view as a synthetic touch down/up -
// called by xr_input.c on the select_click action's edges, only while a
// hand's aim ray currently hits the menu quad.
void xr_menu_touch(float u, float v, bool down);
// Refreshes MenuOverlay's pixels from Java if they changed since the last
// call, and blits them into whichever framebuffer is currently bound -
// xr_session.c binds its shared swapchain image and sets the
// viewport/scissor to the menu's own sub-rectangle within it before calling
// this. Only called while xr_menu_is_visible().
void xr_menu_render_if_visible(void);
void xr_menu_shutdown(void);
#ifdef __cplusplus
}
#endif
#endif
+20
View File
@@ -0,0 +1,20 @@
#include "xr_mouse.h"
#include <SDL.h>
#include "mouse.h" // engine/src/Libraries/INPUT/Source/mouse.h - mouse_put_xy()
void xr_mouse_move(int x, int y) { mouse_put_xy((short)x, (short)y); }
void xr_mouse_click(bool down) {
SDL_Event event;
SDL_zero(event);
event.type = down ? SDL_MOUSEBUTTONDOWN : SDL_MOUSEBUTTONUP;
event.button.timestamp = SDL_GetTicks();
event.button.windowID = 0;
event.button.which = 0;
event.button.button = SDL_BUTTON_LEFT;
event.button.state = down ? SDL_PRESSED : SDL_RELEASED;
event.button.clicks = 1;
SDL_PushEvent(&event);
}
+35
View File
@@ -0,0 +1,35 @@
// Bridges VR aim-ray hit-testing (xr_input.c) to the vendored engine's
// mouse input (engine/src/Libraries/INPUT/Source/mouse.h) - lets the
// controller's laser pointer drive the game's own UI (inventory, menus,
// dialogs) exactly like a desktop mouse: absolute cursor position plus a
// single left button, no relative/mouselook mode, no right button.
#ifndef QUESTSHOCK_XR_MOUSE_H
#define QUESTSHOCK_XR_MOUSE_H
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Sets the engine's absolute mouse position (mouse_put_xy()) - call every
// frame the aim ray is actually hitting the game quad; x/y are pixel
// coordinates in the game's own logical resolution (see
// xr_get_game_resolution() in xr_session.h), already clamped by the
// caller. Do not call on frames the ray isn't hitting the game quad - the
// cursor should hold its last position, not snap elsewhere.
void xr_mouse_move(int x, int y);
// Pushes a synthetic SDL_MOUSEBUTTONDOWN/UP (SDL_BUTTON_LEFT) - matches
// KeyboardOverlay's nativeSendPrintableChar() in questshock_native.c: a
// plain SDL_Event built by hand and handed to the public SDL_PushEvent(),
// no engine patch needed. pump_events() (sdl_events.c) reads whatever
// mouse_put_xy() last set, not any x/y carried on the event itself - call
// xr_mouse_move() first if position needs updating this frame.
void xr_mouse_click(bool down);
#ifdef __cplusplus
}
#endif
#endif
+444
View File
@@ -0,0 +1,444 @@
#include "xr_overlay.h"
#include <dlfcn.h>
#include <stdlib.h>
#include <string.h>
#include <android/log.h>
#include <jni.h>
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#include <SDL.h>
#include "xr_swapchain.h"
#define TAG "QuestShock"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
struct XrOverlay {
XrInstance instance; // for error logging only
int width, height;
float distance_m, half_width_m, half_height_m;
float center_x_m, center_y_m;
bool visible;
XrSwapchainState swapchain;
jclass java_class;
jmethodID native_init_method;
jmethodID take_pixels_method;
jmethodID dispatch_touch_method;
jmethodID update_cursor_method;
// This overlay's content lives in this texture, uploaded by
// xr_overlay_upload_source_texture_if_dirty() below. It's created
// through gl4es (glGenTextures/glBindTexture/glTexImage2D), like any
// other texture the engine itself creates, so ordinary
// glTexSubImage2D uploads against it work the normal way.
GLuint source_texture;
// A framebuffer with source_texture as its only color attachment, used
// as the read source for the blit in
// xr_overlay_render_and_build_layer() below. Created via the real
// (non-gl4es) GLES entry points below: that blit bypasses gl4es
// entirely (see xr_overlay_render_and_build_layer() for why), so its
// source framebuffer has to be a real GL object rather than one gl4es
// tracks.
GLuint source_fbo;
uint32_t pixel_generation;
uint32_t source_uploaded_generation;
uint8_t *pixel_cache; // width*height*4 bytes
};
// This file's other GL calls otherwise resolve to gl4es (the only GL
// symbol provider linked into this binary - see android/engine-patches/
// 02-android-opengl-es.patch), fine for anything shared with the engine's
// own gl4es-routed rendering. But the source FBO's blit (see
// xr_overlay_render_and_build_layer()) needs the real driver directly:
// gl4es's fixed-pipeline emulation unconditionally substitutes its own
// shader onto any gl4es-routed draw call, which would silently replace an
// overlay's content with whatever the game itself last rendered. A
// framebuffer blit has no shader stage at all, so going through the real
// driver for it sidesteps the problem entirely - resolved once here and
// shared by every XrOverlay instance, since they're process-wide function
// pointers regardless of how many overlays exist.
typedef void (*PFNQSGETINTEGERV)(GLenum, GLint *);
typedef void (*PFNQSGENFRAMEBUFFERS)(GLsizei, GLuint *);
typedef void (*PFNQSDELETEFRAMEBUFFERS)(GLsizei, const GLuint *);
typedef void (*PFNQSBINDFRAMEBUFFER)(GLenum, GLuint);
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
typedef void (*PFNQSBLITFRAMEBUFFER)(GLint, GLint, GLint, GLint, GLint, GLint, GLint, GLint,
GLbitfield, GLenum);
static PFNQSGETINTEGERV real_glGetIntegerv;
static PFNQSGENFRAMEBUFFERS real_glGenFramebuffers;
static PFNQSDELETEFRAMEBUFFERS real_glDeleteFramebuffers;
static PFNQSBINDFRAMEBUFFER real_glBindFramebuffer;
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
// glBlitFramebuffer is a GLES 3.0 addition; resolved via eglGetProcAddress
// rather than dlsym, since Android's driver dispatch doesn't guarantee ES3+
// symbols are dlsym-able by name from libGLESv2.so, unlike the GLES2-core
// functions above.
static PFNQSBLITFRAMEBUFFER real_glBlitFramebuffer;
static bool g_real_gles_loaded = false;
static bool xr_overlay_load_real_gles(void) {
if (g_real_gles_loaded)
return true;
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
if (lib == NULL) {
LOGE("XR: overlay dlopen(libGLESv2.so) failed: %s", dlerror());
return false;
}
real_glGetIntegerv = (PFNQSGETINTEGERV)dlsym(lib, "glGetIntegerv");
real_glGenFramebuffers = (PFNQSGENFRAMEBUFFERS)dlsym(lib, "glGenFramebuffers");
real_glDeleteFramebuffers = (PFNQSDELETEFRAMEBUFFERS)dlsym(lib, "glDeleteFramebuffers");
real_glBindFramebuffer = (PFNQSBINDFRAMEBUFFER)dlsym(lib, "glBindFramebuffer");
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
real_glBlitFramebuffer = (PFNQSBLITFRAMEBUFFER)eglGetProcAddress("glBlitFramebuffer");
g_real_gles_loaded = real_glGetIntegerv && real_glGenFramebuffers &&
real_glDeleteFramebuffers && real_glBindFramebuffer &&
real_glFramebufferTexture2D && real_glBlitFramebuffer;
return g_real_gles_loaded;
}
// FindClass() from this thread (SDL's native thread, attached to the JVM
// via AttachCurrentThread rather than spawned from Java) resolves against
// the bootstrap classloader, which only knows framework classes - it can't
// see app classes like de.ladkau.questshock.KeyboardOverlay at all.
// Routing through the activity's own classloader is the standard,
// documented workaround.
static jclass xr_overlay_find_class(JNIEnv *env, jobject activity, const char *name) {
jclass activityClass = (*env)->GetObjectClass(env, activity);
jmethodID getClassLoader =
(*env)->GetMethodID(env, activityClass, "getClassLoader", "()Ljava/lang/ClassLoader;");
jobject classLoader = (*env)->CallObjectMethod(env, activity, getClassLoader);
jclass classLoaderClass = (*env)->FindClass(env, "java/lang/ClassLoader");
jmethodID loadClass = (*env)->GetMethodID(env, classLoaderClass, "loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;");
jstring className = (*env)->NewStringUTF(env, name);
jclass result = (jclass)(*env)->CallObjectMethod(env, classLoader, loadClass, className);
(*env)->DeleteLocalRef(env, className);
return result;
}
static bool xr_overlay_init_jni(XrOverlay *overlay, const char *java_class_name) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
jobject activity = (jobject)SDL_AndroidGetActivity();
if (env == NULL || activity == NULL) {
LOGE("XR: overlay - no JNIEnv/Activity from SDL");
return false;
}
jclass localClass = xr_overlay_find_class(env, activity, java_class_name);
if (localClass == NULL) {
LOGE("XR: overlay - could not find class %s", java_class_name);
return false;
}
overlay->java_class = (jclass)(*env)->NewGlobalRef(env, localClass);
overlay->native_init_method = (*env)->GetStaticMethodID(env, overlay->java_class, "nativeInit",
"(Landroid/app/Activity;)V");
overlay->take_pixels_method =
(*env)->GetStaticMethodID(env, overlay->java_class, "nativeTakePixelsIfDirty", "()[B");
overlay->dispatch_touch_method =
(*env)->GetStaticMethodID(env, overlay->java_class, "nativeDispatchTouch", "(FFZ)V");
overlay->update_cursor_method =
(*env)->GetStaticMethodID(env, overlay->java_class, "nativeUpdateCursor", "(FFZ)V");
if (!overlay->native_init_method || !overlay->take_pixels_method ||
!overlay->dispatch_touch_method || !overlay->update_cursor_method) {
LOGE("XR: overlay - could not resolve %s JNI methods", java_class_name);
return false;
}
(*env)->CallStaticVoidMethod(env, overlay->java_class, overlay->native_init_method, activity);
// CallStaticVoidMethod doesn't surface Java exceptions on its own - if
// the constructor throws (it runs its View measure/layout/draw calls
// on this native render thread rather than the Android UI thread, a
// plausible crash vector), the exception would otherwise be left
// silently pending and corrupt whatever JNI call runs next.
if ((*env)->ExceptionCheck(env)) {
LOGE("XR: overlay %s.nativeInit() threw a pending Java exception:", java_class_name);
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
return false;
}
return true;
}
static bool xr_overlay_create_source_texture(XrOverlay *overlay) {
glGenTextures(1, &overlay->source_texture);
glBindTexture(GL_TEXTURE_2D, overlay->source_texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, overlay->width, overlay->height, 0, GL_RGBA,
GL_UNSIGNED_BYTE, NULL);
GLint prevFbo = 0;
real_glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo);
real_glGenFramebuffers(1, &overlay->source_fbo);
real_glBindFramebuffer(GL_FRAMEBUFFER, overlay->source_fbo);
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
overlay->source_texture, 0);
real_glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prevFbo);
return overlay->source_texture != 0;
}
XrOverlay *xr_overlay_create(XrInstance instance, XrSession session, int64_t swapchain_format,
const XrOverlayConfig *config) {
if (!xr_overlay_load_real_gles()) {
LOGE("XR: overlay couldn't resolve real GLES functions via dlsym/eglGetProcAddress");
return NULL;
}
XrOverlay *overlay = (XrOverlay *)calloc(1, sizeof(XrOverlay));
if (overlay == NULL)
return NULL;
overlay->instance = instance;
overlay->width = config->width;
overlay->height = config->height;
overlay->distance_m = config->distance_m;
overlay->half_width_m = config->width_m * 0.5f;
overlay->half_height_m = config->width_m * 0.5f * (float)config->height / (float)config->width;
overlay->center_x_m = config->default_center_x_m;
overlay->center_y_m = config->default_center_y_m;
if (!xr_swapchain_create(instance, session, swapchain_format, config->width, config->height,
&overlay->swapchain)) {
LOGE("XR: overlay %s swapchain setup failed", config->java_class_name);
free(overlay);
return NULL;
}
if (!xr_overlay_create_source_texture(overlay)) {
xr_swapchain_destroy(&overlay->swapchain);
free(overlay);
return NULL;
}
if (!xr_overlay_init_jni(overlay, config->java_class_name)) {
xr_swapchain_destroy(&overlay->swapchain);
free(overlay);
return NULL;
}
overlay->pixel_cache = (uint8_t *)malloc((size_t)config->width * config->height * 4);
if (overlay->pixel_cache == NULL) {
xr_swapchain_destroy(&overlay->swapchain);
free(overlay);
return NULL;
}
overlay->source_uploaded_generation = (uint32_t)-1;
LOGI("XR: overlay %s ready (%dx%d)", config->java_class_name, config->width, config->height);
return overlay;
}
void xr_overlay_destroy(XrOverlay *overlay) {
if (overlay == NULL)
return;
if (overlay->source_texture != 0)
glDeleteTextures(1, &overlay->source_texture);
if (overlay->source_fbo != 0 && real_glDeleteFramebuffers != NULL)
real_glDeleteFramebuffers(1, &overlay->source_fbo);
if (overlay->java_class != NULL) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env != NULL)
(*env)->DeleteGlobalRef(env, overlay->java_class);
}
xr_swapchain_destroy(&overlay->swapchain);
free(overlay->pixel_cache);
free(overlay);
}
// Every function below tolerates overlay == NULL (a no-op, or the
// obvious "not visible"/zeroed-extent default) - xr_overlay_create() can
// fail (e.g. its backing Java class couldn't be resolved), and unlike the
// single static-global menu quad this replaced, an overlay instance is
// otherwise a heap pointer callers (xr_session.c, questshock_native.c) can
// hold onto and use across frames without re-checking for failure every
// time.
void xr_overlay_toggle_visible(XrOverlay *overlay) {
if (overlay != NULL)
overlay->visible = !overlay->visible;
}
void xr_overlay_set_visible(XrOverlay *overlay, bool visible) {
if (overlay != NULL)
overlay->visible = visible;
}
bool xr_overlay_is_visible(const XrOverlay *overlay) { return overlay != NULL && overlay->visible; }
void xr_overlay_get_quad_extent(const XrOverlay *overlay, float *center_x_m, float *center_y_m,
float *distance_m, float *half_width_m, float *half_height_m) {
if (overlay == NULL) {
*center_x_m = *center_y_m = *distance_m = *half_width_m = *half_height_m = 0.0f;
return;
}
*center_x_m = overlay->center_x_m;
*center_y_m = overlay->center_y_m;
*distance_m = overlay->distance_m;
*half_width_m = overlay->half_width_m;
*half_height_m = overlay->half_height_m;
}
void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_y_m) {
if (overlay == NULL)
return;
overlay->center_x_m = center_x_m;
overlay->center_y_m = center_y_m;
}
void xr_overlay_touch(XrOverlay *overlay, float u, float v, bool down) {
if (overlay == NULL)
return;
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env == NULL)
return;
// Passed via CallStaticVoidMethodA/jvalue rather than the variadic
// Call*Method form - deliberately sidesteps relying on JNI
// implementations correctly un-doing C's float-to-double default
// argument promotion for varargs float parameters (a well-known JNI
// footgun; Android's ART handles it correctly, but there's no reason
// to depend on that when the jvalue form is unambiguous either way).
jvalue args[3];
args[0].f = u;
args[1].f = v;
args[2].z = (jboolean)down;
(*env)->CallStaticVoidMethodA(env, overlay->java_class, overlay->dispatch_touch_method, args);
}
void xr_overlay_update_cursor(XrOverlay *overlay, float u, float v, bool visible) {
if (overlay == NULL)
return;
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env == NULL)
return;
// Same jvalue-form rationale as xr_overlay_touch() above.
jvalue args[3];
args[0].f = u;
args[1].f = v;
args[2].z = (jboolean)visible;
(*env)->CallStaticVoidMethodA(env, overlay->java_class, overlay->update_cursor_method, args);
}
// Pulls the Java view's latest pixels (if it redrew since the last check)
// into pixel_cache and bumps pixel_generation - called once per rendered
// frame, before deciding whether source_texture needs a fresh upload.
static void xr_overlay_refresh_pixel_cache(XrOverlay *overlay) {
JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv();
if (env == NULL)
return;
jbyteArray pixels = (jbyteArray)(*env)->CallStaticObjectMethod(
env, overlay->java_class, overlay->take_pixels_method);
// CallStaticObjectMethod also doesn't surface Java exceptions on its
// own - if nativeTakePixelsIfDirty() itself throws, it would otherwise
// silently look identical to "nothing changed yet" (a null return).
if ((*env)->ExceptionCheck(env)) {
LOGE("XR: overlay nativeTakePixelsIfDirty() threw a pending Java exception:");
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
return;
}
if (pixels == NULL)
return;
(*env)->GetByteArrayRegion(env, pixels, 0, overlay->width * overlay->height * 4,
(jbyte *)overlay->pixel_cache);
(*env)->DeleteLocalRef(env, pixels);
overlay->pixel_generation++;
}
// Uploads pixel_cache into source_texture via plain (gl4es-routed)
// glBindTexture/glTexSubImage2D, matching how the texture was created.
// Skipped when nothing changed since the last upload. Texture-unit-0 state
// is saved and restored around the upload, since this call is gl4es-routed
// and the engine's own next-frame rendering assumes nothing touched its
// texture bindings since it last drew.
static void xr_overlay_upload_source_texture_if_dirty(XrOverlay *overlay) {
if (overlay->source_uploaded_generation == overlay->pixel_generation)
return;
GLint prevActiveTexture = GL_TEXTURE0;
glGetIntegerv(GL_ACTIVE_TEXTURE, &prevActiveTexture);
glActiveTexture(GL_TEXTURE0);
GLint prevTexture2D = 0;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &prevTexture2D);
glBindTexture(GL_TEXTURE_2D, overlay->source_texture);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, overlay->width, overlay->height, GL_RGBA,
GL_UNSIGNED_BYTE, overlay->pixel_cache);
overlay->source_uploaded_generation = overlay->pixel_generation;
glBindTexture(GL_TEXTURE_2D, (GLuint)prevTexture2D);
if (prevActiveTexture != GL_TEXTURE0)
glActiveTexture((GLenum)prevActiveTexture);
}
bool xr_overlay_render_and_build_layer(XrOverlay *overlay, XrCompositionLayerQuad *out_quad) {
if (overlay == NULL || !overlay->visible)
return false;
if (!xr_swapchain_acquire(overlay->instance, &overlay->swapchain))
return false;
xr_overlay_refresh_pixel_cache(overlay);
xr_overlay_upload_source_texture_if_dirty(overlay);
// Copies source_texture (via source_fbo) directly into the swapchain
// image xr_swapchain_acquire() just bound - sized to exactly
// width x height, so the destination is always the whole image - using
// the real GLES3 hardware blit (glBlitFramebuffer) rather than a
// shader-based full-screen-quad draw. A blit has no vertex/fragment
// shading stage, no shader program, and no texture units involved at
// all, so gl4es's fixed-pipeline-emulation layer - which
// unconditionally substitutes a customized shader (reproducing the
// game's own last-bound texture/fixed-function state) onto any
// gl4es-routed draw call - has nothing to intercept here. It also
// never touches gl4es's own tracked program/vertex-array/
// texture-binding shadow state, so the only piece of state that needs
// save/restore is the READ framebuffer binding; binding only
// GL_READ_FRAMEBUFFER, rather than the combined GL_FRAMEBUFFER target,
// leaves the DRAW side (the swapchain image itself) untouched
// throughout.
//
// Y is flipped between source and destination: source_texture's texel
// row v=0 holds the Java view's Bitmap row 0 (the top of the rendered
// content, standard top-row-first raster order), and reading that same
// texture via an attached FBO, framebuffer y=0 accesses that identical
// row. The destination swapchain framebuffer follows the opposite
// convention - its own y=0 is its bottom edge - so swapping dstY0/dstY1
// keeps the content right-side up (glBlitFramebuffer supports inverted
// src/dst rects for exactly this).
GLint prevReadFbo = 0;
real_glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prevReadFbo);
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, overlay->source_fbo);
real_glBlitFramebuffer(0, 0, overlay->width, overlay->height, 0, overlay->height,
overlay->width, 0, GL_COLOR_BUFFER_BIT, GL_LINEAR);
real_glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)prevReadFbo);
xr_swapchain_release(overlay->instance, &overlay->swapchain);
memset(out_quad, 0, sizeof(*out_quad));
out_quad->type = XR_TYPE_COMPOSITION_LAYER_QUAD;
out_quad->subImage.swapchain = overlay->swapchain.swapchain;
out_quad->subImage.imageRect.extent.width = overlay->swapchain.width;
out_quad->subImage.imageRect.extent.height = overlay->swapchain.height;
out_quad->pose.orientation.w = 1.0f;
out_quad->pose.position.x = overlay->center_x_m;
out_quad->pose.position.y = overlay->center_y_m;
out_quad->pose.position.z = -overlay->distance_m;
out_quad->size.width = overlay->half_width_m * 2.0f;
out_quad->size.height = overlay->half_height_m * 2.0f;
return true;
}
+96
View File
@@ -0,0 +1,96 @@
// Generic "off-screen Android View rendered into its own OpenXR quad,
// hit-tested/touched via a laser pointer" module - each instance owns its
// own swapchain (via xr_swapchain.h), its own visible/position state, and
// the JNI glue to a backing Java class (menu launcher, keyboard, or any
// future such panel) that must expose these static methods, matching the
// pattern MenuOverlay.java already established:
// static void nativeInit(Activity activity)
// static byte[] nativeTakePixelsIfDirty()
// static void nativeDispatchTouch(float u, float v, boolean down)
// static void nativeUpdateCursor(float u, float v, boolean visible)
//
// xr_input.c drives touch/cursor dispatch and (for panels that support it,
// like the keyboard's title bar) dragging via xr_overlay_set_position();
// xr_session.c owns instance lifetime and calls
// xr_overlay_render_and_build_layer() once per frame per instance.
#ifndef QUESTSHOCK_XR_OVERLAY_H
#define QUESTSHOCK_XR_OVERLAY_H
#include <stdbool.h>
#include <stdint.h>
#define XR_USE_PLATFORM_ANDROID 1
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
#include <openxr/openxr.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct XrOverlay XrOverlay;
typedef struct {
// Fully-qualified Java class name backing this overlay, e.g.
// "de.ladkau.questshock.KeyboardOverlay" - resolved via the activity's
// own ClassLoader (see xr_overlay_find_class() in xr_overlay.c).
const char *java_class_name;
// Pixel size of the overlay's own content/swapchain.
int width;
int height;
// Initial local-space pose (meters, in front of the viewer along -Z) -
// see xr_overlay_get_quad_extent(). width_m is the quad's physical
// width; its height is derived to preserve width/height's aspect.
float distance_m;
float width_m;
float default_center_x_m;
float default_center_y_m;
} XrOverlayConfig;
// instance/session are used to create this overlay's own swapchain
// (format shared with every other quad this app submits - see
// xr_session.c's chosenFormat). Returns NULL (logged) on failure - the
// caller just has no such panel.
XrOverlay *xr_overlay_create(XrInstance instance, XrSession session, int64_t swapchain_format,
const XrOverlayConfig *config);
void xr_overlay_destroy(XrOverlay *overlay);
void xr_overlay_toggle_visible(XrOverlay *overlay);
void xr_overlay_set_visible(XrOverlay *overlay, bool visible);
bool xr_overlay_is_visible(const XrOverlay *overlay);
// Local-space pose/size of the quad, valid regardless of visibility -
// shared with xr_input.c's ray/quad hit-testing.
void xr_overlay_get_quad_extent(const XrOverlay *overlay, float *center_x_m, float *center_y_m,
float *distance_m, float *half_width_m, float *half_height_m);
// Repositions the quad (world-space X/Y offset, same units/space as
// xr_overlay_get_quad_extent()'s center_x_m/center_y_m) - e.g. driven by
// xr_input.c while the user drags a panel's title bar. Distance from the
// viewer isn't adjustable this way - only left/right/up/down
// repositioning, not push/pull.
void xr_overlay_set_position(XrOverlay *overlay, float center_x_m, float center_y_m);
// Forwards a hit on the quad (in the quad's own 0..1 u/v, top-left origin)
// to the backing Java view as a synthetic touch down/up.
void xr_overlay_touch(XrOverlay *overlay, float u, float v, bool down);
// Updates the on-quad cursor the Java view draws at the current aim hit
// point (same u/v convention as xr_overlay_touch()) - called once per
// frame regardless of click state, so the cursor tracks the ray
// continuously. visible=false hides it.
void xr_overlay_update_cursor(XrOverlay *overlay, float u, float v, bool visible);
// If visible: acquires this overlay's next swapchain image, blits the
// Java view's latest rendered pixels into it, releases the image, and
// fills *out_quad's subImage/pose/size. space/eyeVisibility/layerFlags are
// left for the caller to set (shared/policy choices across every layer
// this app submits, e.g. translucency - not this module's concern).
// Returns false (out_quad untouched) if not visible or something failed -
// the caller should skip submitting a layer for it this frame.
bool xr_overlay_render_and_build_layer(XrOverlay *overlay, XrCompositionLayerQuad *out_quad);
#ifdef __cplusplus
}
#endif
#endif
+142 -221
View File
@@ -1,6 +1,5 @@
#include "xr_session.h" #include "xr_session.h"
#include <dlfcn.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@@ -18,7 +17,8 @@
#include <SDL.h> #include <SDL.h>
#include "xr_input.h" #include "xr_input.h"
#include "xr_menu.h" #include "xr_overlay.h"
#include "xr_swapchain.h"
#define TAG "QuestShock" #define TAG "QuestShock"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
@@ -33,9 +33,26 @@
#define QUAD_DISTANCE_METERS 2.0f #define QUAD_DISTANCE_METERS 2.0f
#define QUAD_WIDTH_METERS 1.6f #define QUAD_WIDTH_METERS 1.6f
// Up to this many swapchain images/FBOs - real runtimes report small counts // The menu launcher (just a "Keyboard" button - see MenuOverlay.java) is
// (2-4); this is just a fixed upper bound for the cache arrays below. // small and sits above where the keyboard defaults to; the keyboard keeps
#define MAX_SWAPCHAIN_IMAGES 8 // its previous size/position. They're independent XrOverlay instances (see
// xr_overlay.h) so both can be shown/hidden/dragged separately - deliberately
// positioned apart by default so they don't start out overlapping.
#define MENU_JAVA_CLASS "de.ladkau.questshock.MenuOverlay"
#define MENU_WIDTH 512
#define MENU_HEIGHT 256
#define MENU_DISTANCE_METERS 1.3f
#define MENU_WIDTH_METERS 0.35f
#define MENU_DEFAULT_CENTER_X_METERS 0.0f
#define MENU_DEFAULT_CENTER_Y_METERS 0.15f
#define KEYBOARD_JAVA_CLASS "de.ladkau.questshock.KeyboardOverlay"
#define KEYBOARD_WIDTH 1024
#define KEYBOARD_HEIGHT 768
#define KEYBOARD_DISTANCE_METERS 1.5f
#define KEYBOARD_WIDTH_METERS 0.8f
#define KEYBOARD_DEFAULT_CENTER_X_METERS 0.0f
#define KEYBOARD_DEFAULT_CENTER_Y_METERS -0.45f
static XrInstance g_instance = XR_NULL_HANDLE; static XrInstance g_instance = XR_NULL_HANDLE;
static XrSystemId g_system_id = XR_NULL_SYSTEM_ID; static XrSystemId g_system_id = XR_NULL_SYSTEM_ID;
@@ -54,55 +71,13 @@ static XrSessionState g_session_state = XR_SESSION_STATE_UNKNOWN;
// later state. // later state.
static bool g_session_running = false; static bool g_session_running = false;
// A single shared swapchain, wide enough to hold the game's own content static XrSwapchainState g_game_swapchain;
// (left) and the menu's content (right) side by side - see xr_frame_end() static XrOverlay *g_menu_overlay = NULL;
// for how each gets its own imageRect/viewport/scissor sub-rectangle within static XrOverlay *g_keyboard_overlay = NULL;
// it. Deliberately not two independent swapchains: submitting two
// separately-created swapchains as two XrCompositionLayerQuads makes the
// Horizon OS compositor show the game's own content on the menu quad
// instead of the menu's - a compositor-level limitation with that
// configuration, not something fixable from app-side GL/OpenXR code.
// Sharing one swapchain avoids it entirely.
static XrSwapchain g_swapchain = XR_NULL_HANDLE;
static int g_game_width = 0;
static int g_game_height = 0;
static int g_menu_width = 0;
static int g_menu_height = 0;
static GLuint g_swapchain_fbos[MAX_SWAPCHAIN_IMAGES];
static uint32_t g_swapchain_image_count = 0;
static XrTime g_predicted_display_time = 0; static XrTime g_predicted_display_time = 0;
static bool g_frame_should_render = false; static bool g_frame_should_render = false;
static bool g_have_acquired_image = false; static bool g_have_acquired_game_image = false;
// This file's GL calls otherwise resolve to gl4es (the only GL symbol
// provider linked into this binary - see android/engine-patches/
// 02-android-opengl-es.patch), which is fine for anything shared with the
// engine's own gl4es-routed rendering. But the swapchain images OpenXR
// hands us are real driver texture objects gl4es never created itself,
// and gl4es's own glFramebufferTexture2D can't attach a texture it has no
// tracked metadata for. So the FBO *container* is created via gl4es's own
// glGenFramebuffers/glBindFramebuffer (so gl4es recognizes the id as its
// own and its own per-frame glBindFramebuffer succeeds), while the
// texture-attach step - the specifically foreign part - goes through the
// real driver directly, via dlsym against libGLESv2.so.
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
typedef GLenum (*PFNQSCHECKFRAMEBUFFERSTATUS)(GLenum);
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
static PFNQSCHECKFRAMEBUFFERSTATUS real_glCheckFramebufferStatus;
static bool xr_load_real_gles(void) {
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
if (lib == NULL) {
LOGE("XR: dlopen(libGLESv2.so) failed: %s", dlerror());
return false;
}
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
real_glCheckFramebufferStatus =
(PFNQSCHECKFRAMEBUFFERSTATUS)dlsym(lib, "glCheckFramebufferStatus");
return real_glFramebufferTexture2D && real_glCheckFramebufferStatus;
}
static bool xr_check(XrResult result, const char *what) { static bool xr_check(XrResult result, const char *what) {
if (XR_SUCCEEDED(result)) if (XR_SUCCEEDED(result))
@@ -202,10 +177,12 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
// Required by spec before creating an OpenGL ES-backed session, even // Required by spec before creating an OpenGL ES-backed session, even
// though we don't gate on the returned min/max API version here. // though we don't gate on the returned min/max API version here.
PFN_xrGetOpenGLESGraphicsRequirementsKHR getGLESRequirements = PFN_xrGetOpenGLESGraphicsRequirementsKHR getGLESRequirements =
(PFN_xrGetOpenGLESGraphicsRequirementsKHR)xr_get_proc("xrGetOpenGLESGraphicsRequirementsKHR"); (PFN_xrGetOpenGLESGraphicsRequirementsKHR)xr_get_proc(
"xrGetOpenGLESGraphicsRequirementsKHR");
if (getGLESRequirements == NULL) if (getGLESRequirements == NULL)
return false; return false;
XrGraphicsRequirementsOpenGLESKHR glesRequirements = {XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR}; XrGraphicsRequirementsOpenGLESKHR glesRequirements = {
XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
if (!xr_check(getGLESRequirements(g_instance, g_system_id, &glesRequirements), if (!xr_check(getGLESRequirements(g_instance, g_system_id, &glesRequirements),
"xrGetOpenGLESGraphicsRequirementsKHR")) "xrGetOpenGLESGraphicsRequirementsKHR"))
return false; return false;
@@ -236,17 +213,16 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
"xrCreateReferenceSpace")) "xrCreateReferenceSpace"))
return false; return false;
// Non-fatal if it fails (e.g. no controllers bound yet) - rendering
// keeps working either way, just without the laser pointer.
xr_input_init(g_instance, g_session);
// Prefer a plain linear 8-bit format over GL_SRGB8_ALPHA8: the source // Prefer a plain linear 8-bit format over GL_SRGB8_ALPHA8: the source
// SDL surface pixels are already sRGB-encoded (as ordinary 8-bit image // SDL surface pixels are already sRGB-encoded (as ordinary 8-bit image
// data conventionally is) and get uploaded/sampled as plain linear // data conventionally is) and get uploaded/sampled as plain linear
// GL_RGBA with no decode step anywhere in this path, matching the // GL_RGBA with no decode step anywhere in this path, matching the
// desktop SDL_RenderCopy path this replaces - an sRGB swapchain format // desktop SDL_RenderCopy path this replaces - an sRGB swapchain format
// would auto-gamma-encode on write and double-encode already-encoded // would auto-gamma-encode on write and double-encode already-encoded
// data. // data. Every swapchain below (game quad, menu/keyboard overlays, and
// - via xr_input_init() - the per-hand laser-beam overlays) shares
// this one choice - the compositor's supported format set doesn't
// depend on swapchain size.
uint32_t formatCount = 0; uint32_t formatCount = 0;
xrEnumerateSwapchainFormats(g_session, 0, &formatCount, NULL); xrEnumerateSwapchainFormats(g_session, 0, &formatCount, NULL);
int64_t *formats = (int64_t *)malloc(sizeof(int64_t) * formatCount); int64_t *formats = (int64_t *)malloc(sizeof(int64_t) * formatCount);
@@ -261,82 +237,42 @@ static bool xr_create_instance_and_session(int game_width, int game_height) {
} }
free(formats); free(formats);
g_game_width = game_width; // Non-fatal if it fails (e.g. no controllers bound yet) - rendering
g_game_height = game_height; // keeps working either way, just without the laser pointer/beams.
xr_menu_get_content_size(&g_menu_width, &g_menu_height); xr_input_init(g_instance, g_session, chosenFormat);
// The game's own content occupies (0,0)-(game_width,game_height); the
// menu's occupies (game_width,0)-(game_width+menu_width,menu_height) -
// side by side in one image, wide enough and tall enough for both.
uint32_t sharedWidth = (uint32_t)(g_game_width + g_menu_width);
uint32_t sharedHeight = (uint32_t)(g_game_height > g_menu_height ? g_game_height : g_menu_height);
XrSwapchainCreateInfo swapchainInfo = {XR_TYPE_SWAPCHAIN_CREATE_INFO}; if (!xr_swapchain_create(g_instance, g_session, chosenFormat, game_width, game_height,
swapchainInfo.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT; &g_game_swapchain)) {
swapchainInfo.format = chosenFormat; LOGE("XR: game swapchain setup failed");
swapchainInfo.sampleCount = 1;
swapchainInfo.width = sharedWidth;
swapchainInfo.height = sharedHeight;
swapchainInfo.faceCount = 1;
swapchainInfo.arraySize = 1;
swapchainInfo.mipCount = 1;
if (!xr_check(xrCreateSwapchain(g_session, &swapchainInfo, &g_swapchain),
"xrCreateSwapchain"))
return false;
uint32_t imageCount = 0;
xrEnumerateSwapchainImages(g_swapchain, 0, &imageCount, NULL);
if (imageCount > MAX_SWAPCHAIN_IMAGES) {
LOGE("XR: swapchain reports %u images, only room for %d", imageCount,
MAX_SWAPCHAIN_IMAGES);
return false;
}
// Zero-initialized, not just `.type` set per element - these structs
// also carry a `next` field the runtime may read, and an
// uninitialized stack array would leave it as garbage.
XrSwapchainImageOpenGLESKHR images[MAX_SWAPCHAIN_IMAGES] = {0};
for (uint32_t i = 0; i < imageCount; i++)
images[i].type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR;
if (!xr_check(xrEnumerateSwapchainImages(g_swapchain, imageCount, &imageCount,
(XrSwapchainImageBaseHeader *)images),
"xrEnumerateSwapchainImages"))
return false;
g_swapchain_image_count = imageCount;
if (!xr_load_real_gles()) {
LOGE("XR: couldn't resolve real GLES FBO functions via dlsym");
return false; return false;
} }
// Wraps each swapchain-provided texture in its own framebuffer, matching // Both non-fatal - the game keeps rendering with no such panel if
// how OpenGL.cc's own CreateFrameBuffer() wraps backupBuffer - just // either fails (e.g. the Java class couldn't be resolved via JNI).
// without a depth/stencil attachment, since the final composite draw XrOverlayConfig menuConfig = {
// (see opengl_swap_and_restore) never needs one. The Gen/Bind calls are .java_class_name = MENU_JAVA_CLASS,
// gl4es's own (linked, not dlsym'd) - see the comment above .width = MENU_WIDTH,
// real_glFramebufferTexture2D for why. .height = MENU_HEIGHT,
bool all_complete = true; .distance_m = MENU_DISTANCE_METERS,
for (uint32_t i = 0; i < imageCount; i++) { .width_m = MENU_WIDTH_METERS,
glGenFramebuffers(1, &g_swapchain_fbos[i]); .default_center_x_m = MENU_DEFAULT_CENTER_X_METERS,
glBindFramebuffer(GL_FRAMEBUFFER, g_swapchain_fbos[i]); .default_center_y_m = MENU_DEFAULT_CENTER_Y_METERS,
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, };
images[i].image, 0); g_menu_overlay = xr_overlay_create(g_instance, g_session, chosenFormat, &menuConfig);
GLenum status = real_glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) {
LOGE("XR: swapchain FBO %u incomplete: 0x%x", i, status);
all_complete = false;
}
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (!all_complete)
return false;
LOGI("XR: instance/session/swapchain ready (%ux%u shared, %dx%d game + %dx%d menu, %u images)", XrOverlayConfig keyboardConfig = {
sharedWidth, sharedHeight, g_game_width, g_game_height, g_menu_width, g_menu_height, .java_class_name = KEYBOARD_JAVA_CLASS,
imageCount); .width = KEYBOARD_WIDTH,
.height = KEYBOARD_HEIGHT,
// Also non-fatal - the game keeps rendering with no menu quad if this .distance_m = KEYBOARD_DISTANCE_METERS,
// fails (e.g. MenuOverlay couldn't be resolved via JNI). .width_m = KEYBOARD_WIDTH_METERS,
xr_menu_init(g_instance, g_session); .default_center_x_m = KEYBOARD_DEFAULT_CENTER_X_METERS,
.default_center_y_m = KEYBOARD_DEFAULT_CENTER_Y_METERS,
};
g_keyboard_overlay = xr_overlay_create(g_instance, g_session, chosenFormat, &keyboardConfig);
LOGI("XR: instance/session ready (game %dx%d, menu %dx%d, keyboard %dx%d)", game_width,
game_height, MENU_WIDTH, MENU_HEIGHT, KEYBOARD_WIDTH, KEYBOARD_HEIGHT);
return true; return true;
} }
@@ -349,6 +285,9 @@ bool xr_init(int game_width, int game_height) {
return true; return true;
} }
XrOverlay *xr_session_get_menu_overlay(void) { return g_menu_overlay; }
XrOverlay *xr_session_get_keyboard_overlay(void) { return g_keyboard_overlay; }
void xr_poll_events(void) { void xr_poll_events(void) {
if (g_instance == XR_NULL_HANDLE) if (g_instance == XR_NULL_HANDLE)
return; return;
@@ -369,7 +308,8 @@ void xr_poll_events(void) {
if (g_session_state == XR_SESSION_STATE_READY) { if (g_session_state == XR_SESSION_STATE_READY) {
XrSessionBeginInfo beginInfo = {XR_TYPE_SESSION_BEGIN_INFO}; XrSessionBeginInfo beginInfo = {XR_TYPE_SESSION_BEGIN_INFO};
beginInfo.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; beginInfo.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
g_session_running = xr_check(xrBeginSession(g_session, &beginInfo), "xrBeginSession"); g_session_running =
xr_check(xrBeginSession(g_session, &beginInfo), "xrBeginSession");
} else if (g_session_state == XR_SESSION_STATE_STOPPING) { } else if (g_session_state == XR_SESSION_STATE_STOPPING) {
xr_check(xrEndSession(g_session), "xrEndSession"); xr_check(xrEndSession(g_session), "xrEndSession");
g_session_running = false; g_session_running = false;
@@ -384,7 +324,7 @@ void xr_poll_events(void) {
bool xr_is_session_running(void) { return g_session_running; } bool xr_is_session_running(void) { return g_session_running; }
bool xr_frame_begin(void) { bool xr_frame_begin(void) {
g_have_acquired_image = false; g_have_acquired_game_image = false;
if (!xr_is_session_running()) if (!xr_is_session_running())
return false; return false;
@@ -402,113 +342,93 @@ bool xr_frame_begin(void) {
if (!g_frame_should_render) if (!g_frame_should_render)
return false; return false;
uint32_t imageIndex = 0; g_have_acquired_game_image = xr_swapchain_acquire(g_instance, &g_game_swapchain);
XrSwapchainImageAcquireInfo acquireInfo = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; return g_have_acquired_game_image;
if (!xr_check(xrAcquireSwapchainImage(g_swapchain, &acquireInfo, &imageIndex),
"xrAcquireSwapchainImage"))
return false;
XrSwapchainImageWaitInfo waitImageInfo = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};
waitImageInfo.timeout = XR_INFINITE_DURATION;
if (!xr_check(xrWaitSwapchainImage(g_swapchain, &waitImageInfo), "xrWaitSwapchainImage"))
return false;
// gl4es's own (linked, not dlsym'd) bind - this targets an FBO id
// gl4es itself created (see xr_create_instance_and_session()), so its
// own "current FBO" bookkeeping updates correctly and its immediate-
// mode draw calls in android_draw_surface_as_quad() land in the right
// place. Scissor-limited to the game's own sub-rectangle of the shared
// image (see g_swapchain's comment) - glViewport alone would already
// keep the engine's draw calls within that rectangle, but glClear
// ignores viewport and would otherwise wipe the menu's own region of
// the same image whenever the engine clears before drawing.
glBindFramebuffer(GL_FRAMEBUFFER, g_swapchain_fbos[imageIndex]);
glViewport(0, 0, g_game_width, g_game_height);
glEnable(GL_SCISSOR_TEST);
glScissor(0, 0, g_game_width, g_game_height);
g_have_acquired_image = true;
return true;
} }
void xr_frame_end(void) { void xr_frame_end(void) {
if (g_session == XR_NULL_HANDLE) if (g_session == XR_NULL_HANDLE)
return; return;
// Draw the laser-pointer reticle into the still-bound swapchain // Syncs actions and hit-tests/dispatches each hand's ray against
// framebuffer before releasing it, so it composites on top of // keyboard/menu/game quad (see xr_input.c) before the game swapchain
// whatever this frame's game content already drew there. Syncing // image is released below - this always runs, even when there's
// actions happens even when there's nothing to draw (no acquired // nothing to draw (no acquired image this frame), so edge detection
// image this frame), so edge detection (trigger/menu-button clicks) // (trigger/menu-button clicks) doesn't miss a frame. Any resulting
// doesn't miss a frame. Only ever draws while the menu isn't visible // laser-beam quads are submitted as their own composition layers
// (see xr_input.c), so it never needs to know about the menu's own // further down, not drawn into the game swapchain itself.
// sub-rectangle below.
if (xr_is_session_running()) if (xr_is_session_running())
xr_input_sync_and_draw(g_local_space, g_predicted_display_time, g_have_acquired_image); xr_input_sync_and_draw(g_local_space, g_predicted_display_time,
g_have_acquired_game_image);
bool menuVisible = g_have_acquired_image && xr_menu_is_visible(); if (g_have_acquired_game_image)
if (menuVisible) { xr_swapchain_release(g_instance, &g_game_swapchain);
// Switch viewport/scissor to the menu's own sub-rectangle of the
// same shared image (to the right of the game's own region, see
// g_swapchain's comment) and let xr_menu.c draw its content there -
// still the same framebuffer bound in xr_frame_begin(), just a
// different sub-rectangle of it.
glViewport(g_game_width, 0, g_menu_width, g_menu_height);
glScissor(g_game_width, 0, g_menu_width, g_menu_height);
xr_menu_render_if_visible();
}
if (g_have_acquired_image) {
glDisable(GL_SCISSOR_TEST);
XrSwapchainImageReleaseInfo releaseInfo = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
xr_check(xrReleaseSwapchainImage(g_swapchain, &releaseInfo), "xrReleaseSwapchainImage");
}
if (!xr_is_session_running()) if (!xr_is_session_running())
return; return;
// Up to 5 layers: the game quad (if a frame was actually rendered),
// the menu launcher and keyboard overlays while visible - each gets
// its own acquire/render/release cycle against its own swapchain (see
// xr_overlay_render_and_build_layer()) any time before xrEndFrame,
// unlike the game quad there's no per-frame engine rendering to wrap
// around here, just each overlay's own blit - and, per hand, a
// laser-beam quad while that hand's ray is aimed at the keyboard,
// menu, or the game quad itself (see xr_input_get_beam_layer()).
XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD}; XrCompositionLayerQuad gameQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD};
gameQuad.space = g_local_space; gameQuad.space = g_local_space;
gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; gameQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
gameQuad.subImage.swapchain = g_swapchain; gameQuad.subImage.swapchain = g_game_swapchain.swapchain;
gameQuad.subImage.imageRect.offset.x = 0; gameQuad.subImage.imageRect.extent.width = g_game_swapchain.width;
gameQuad.subImage.imageRect.offset.y = 0; gameQuad.subImage.imageRect.extent.height = g_game_swapchain.height;
gameQuad.subImage.imageRect.extent.width = g_game_width;
gameQuad.subImage.imageRect.extent.height = g_game_height;
gameQuad.pose.orientation.w = 1.0f; gameQuad.pose.orientation.w = 1.0f;
gameQuad.pose.position.z = -QUAD_DISTANCE_METERS; gameQuad.pose.position.z = -QUAD_DISTANCE_METERS;
gameQuad.size.width = QUAD_WIDTH_METERS; gameQuad.size.width = QUAD_WIDTH_METERS;
gameQuad.size.height = QUAD_WIDTH_METERS * (float)g_game_height / (float)g_game_width; gameQuad.size.height =
QUAD_WIDTH_METERS * (float)g_game_swapchain.height / (float)g_game_swapchain.width;
// Up to 2 layers: the game quad (if a frame was actually rendered) and, const XrCompositionLayerBaseHeader *layers[5];
// while toggled on, the menu quad in front of it - see xr_input.c's
// menu_toggle handling. Both reference the SAME g_swapchain, just
// different imageRect sub-rectangles within it.
const XrCompositionLayerBaseHeader *layers[2];
uint32_t layerCount = 0; uint32_t layerCount = 0;
if (g_have_acquired_image) if (g_have_acquired_game_image)
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad; layers[layerCount++] = (XrCompositionLayerBaseHeader *)&gameQuad;
XrCompositionLayerQuad menuQuad = {XR_TYPE_COMPOSITION_LAYER_QUAD}; XrCompositionLayerQuad menuQuad;
if (menuVisible) { if (g_frame_should_render && xr_overlay_render_and_build_layer(g_menu_overlay, &menuQuad)) {
float centerX, centerY, distance, halfWidth, halfHeight;
xr_menu_get_quad_extent(&centerX, &centerY, &distance, &halfWidth, &halfHeight);
menuQuad.space = g_local_space; menuQuad.space = g_local_space;
menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; menuQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
menuQuad.subImage.swapchain = g_swapchain;
menuQuad.subImage.imageRect.offset.x = g_game_width;
menuQuad.subImage.imageRect.offset.y = 0;
menuQuad.subImage.imageRect.extent.width = g_menu_width;
menuQuad.subImage.imageRect.extent.height = g_menu_height;
menuQuad.pose.orientation.w = 1.0f;
menuQuad.pose.position.x = centerX;
menuQuad.pose.position.y = centerY;
menuQuad.pose.position.z = -distance;
menuQuad.size.width = halfWidth * 2.0f;
menuQuad.size.height = halfHeight * 2.0f;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&menuQuad; layers[layerCount++] = (XrCompositionLayerBaseHeader *)&menuQuad;
} }
XrCompositionLayerQuad keyboardQuad;
if (g_frame_should_render &&
xr_overlay_render_and_build_layer(g_keyboard_overlay, &keyboardQuad)) {
keyboardQuad.space = g_local_space;
keyboardQuad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
// Lets the keyboard sit on screen translucently while playing
// rather than fully hiding whatever's behind it - KeyboardOverlay
// writes real sub-255 alpha into the swapchain texture (see
// OverlayPanel.compositeAndPublish()), already in Android's
// default premultiplied format, which is what this flag's absence
// of UNPREMULTIPLIED_ALPHA_BIT assumes. The menu launcher above
// deliberately doesn't get this flag - it stays fully opaque.
keyboardQuad.layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&keyboardQuad;
}
// Submitted last (frontmost) so a beam doesn't z-fight against the
// panel surface it's aimed at/terminates on - see
// xr_input_get_beam_layer(). Translucent for the same reason the
// keyboard is (see comment above).
XrCompositionLayerQuad beamQuad[2];
for (int hand = 0; hand < 2; hand++) {
if (xr_input_get_beam_layer(hand, &beamQuad[hand])) {
beamQuad[hand].space = g_local_space;
beamQuad[hand].eyeVisibility = XR_EYE_VISIBILITY_BOTH;
beamQuad[hand].layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
layers[layerCount++] = (XrCompositionLayerBaseHeader *)&beamQuad[hand];
}
}
XrFrameEndInfo endInfo = {XR_TYPE_FRAME_END_INFO}; XrFrameEndInfo endInfo = {XR_TYPE_FRAME_END_INFO};
endInfo.displayTime = g_predicted_display_time; endInfo.displayTime = g_predicted_display_time;
endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE;
@@ -518,15 +438,7 @@ void xr_frame_end(void) {
} }
void xr_shutdown(void) { void xr_shutdown(void) {
for (uint32_t i = 0; i < g_swapchain_image_count; i++) { xr_swapchain_destroy(&g_game_swapchain);
if (g_swapchain_fbos[i] != 0)
glDeleteFramebuffers(1, &g_swapchain_fbos[i]);
}
g_swapchain_image_count = 0;
if (g_swapchain != XR_NULL_HANDLE)
xrDestroySwapchain(g_swapchain);
g_swapchain = XR_NULL_HANDLE;
if (g_local_space != XR_NULL_HANDLE) if (g_local_space != XR_NULL_HANDLE)
xrDestroySpace(g_local_space); xrDestroySpace(g_local_space);
@@ -534,7 +446,10 @@ void xr_shutdown(void) {
// Before the session/instance they were created from. // Before the session/instance they were created from.
xr_input_shutdown(); xr_input_shutdown();
xr_menu_shutdown(); xr_overlay_destroy(g_menu_overlay);
g_menu_overlay = NULL;
xr_overlay_destroy(g_keyboard_overlay);
g_keyboard_overlay = NULL;
if (g_session != XR_NULL_HANDLE) if (g_session != XR_NULL_HANDLE)
xrDestroySession(g_session); xrDestroySession(g_session);
@@ -551,5 +466,11 @@ void xr_shutdown(void) {
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m) { void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m) {
*distance_m = QUAD_DISTANCE_METERS; *distance_m = QUAD_DISTANCE_METERS;
*half_width_m = QUAD_WIDTH_METERS * 0.5f; *half_width_m = QUAD_WIDTH_METERS * 0.5f;
*half_height_m = QUAD_WIDTH_METERS * 0.5f * (float)g_game_height / (float)g_game_width; *half_height_m =
QUAD_WIDTH_METERS * 0.5f * (float)g_game_swapchain.height / (float)g_game_swapchain.width;
}
void xr_get_game_resolution(int *width, int *height) {
*width = g_game_swapchain.width;
*height = g_game_swapchain.height;
} }
+44 -18
View File
@@ -1,28 +1,43 @@
// Minimal OpenXR bring-up for questshock's immersive Quest build. Keeps the // Minimal OpenXR bring-up for questshock's immersive Quest build. Keeps the
// game's own rendering untouched (see android/engine-patches/ // game's own rendering untouched (see android/engine-patches/
// 11-android-openxr-present.patch) - this module only owns the OpenXR // 11-android-openxr-present.patch) - this module only owns the OpenXR
// instance/session/swapchain and the final "submit a quad layer instead of // instance/session/swapchains and the final "submit quad layers instead of
// presenting to a window" step. No stereo rendering, no controller input // presenting to a window" step. No stereo rendering - the game composite is
// yet (see the plan's steps B/C/D for those) - the game composite is shown // shown as a single flat quad floating in front of the viewer.
// as a single flat quad floating in front of the viewer.
#ifndef QUESTSHOCK_XR_SESSION_H #ifndef QUESTSHOCK_XR_SESSION_H
#define QUESTSHOCK_XR_SESSION_H #define QUESTSHOCK_XR_SESSION_H
#include <stdbool.h> #include <stdbool.h>
#include "xr_overlay.h"
#ifdef __cplusplus #ifdef __cplusplus
extern "C" { extern "C" {
#endif #endif
// Call once, right after init_opengl() (OpenGL.cc) has created and made // Call once, right after init_opengl() (OpenGL.cc) has created and made
// current the GL context SDL/gl4es already use - creates the OpenXR // current the GL context SDL/gl4es already use - creates the OpenXR
// instance/session sharing that same EGL display/context, plus one // instance/session sharing that same EGL display/context, plus the game
// swapchain sized to the game's logical resolution (game_width/height, i.e. // quad's own swapchain, sized to the game's logical resolution
// grd_cap->w/h - see Shock.c's InitSDL()). Returns false if OpenXR bring-up // (game_width/height, i.e. grd_cap->w/h - see Shock.c's InitSDL()). Also
// failed (e.g. no runtime installed) - callers should fall back to the // creates the menu launcher and keyboard overlay quads (see xr_overlay.h) -
// existing window-present path in that case. // two independent XrOverlay instances, each with their own swapchain,
// visibility, and position, retrievable via xr_session_get_menu_overlay()/
// xr_session_get_keyboard_overlay() below. Returns false if OpenXR
// bring-up failed (e.g. no runtime installed) - callers should fall back
// to the existing window-present path in that case.
bool xr_init(int game_width, int game_height); bool xr_init(int game_width, int game_height);
// The menu launcher (small "Keyboard" button panel, toggled by the
// controller's menu button) and keyboard (the on-screen key grid, shown/
// hidden independently via MenuOverlay's "Keyboard" button and
// KeyboardOverlay's own Close button) overlay quads - used by xr_input.c
// for ray/quad hit-testing and touch/cursor dispatch, and by
// questshock_native.c's JNI glue (nativeShowKeyboard()/
// nativeRequestClose()). NULL if xr_init() failed before creating them.
XrOverlay *xr_session_get_menu_overlay(void);
XrOverlay *xr_session_get_keyboard_overlay(void);
// Pumps XR session-state events. Call once per frame, before // Pumps XR session-state events. Call once per frame, before
// xr_frame_begin(). Must still be called even when xr_init() returned // xr_frame_begin(). Must still be called even when xr_init() returned
// false (no-op in that case). // false (no-op in that case).
@@ -34,17 +49,21 @@ void xr_poll_events(void);
bool xr_is_session_running(void); bool xr_is_session_running(void);
// Begins the XR frame (xrWaitFrame/xrBeginFrame) and, if the runtime wants // Begins the XR frame (xrWaitFrame/xrBeginFrame) and, if the runtime wants
// this frame rendered, acquires the next swapchain image and binds its // this frame rendered, acquires the game quad's next swapchain image and
// framebuffer as the current render target - ready for the caller to draw // binds its framebuffer as the current render target - ready for the
// into exactly as it would have drawn to the default framebuffer. Returns // caller to draw into exactly as it would have drawn to the default
// true if the caller should draw this frame; xr_frame_end() must be called // framebuffer. Returns true if the caller should draw this frame;
// unconditionally afterward either way (a begun XR frame must always be // xr_frame_end() must be called unconditionally afterward either way (a
// ended, rendered or not). // begun XR frame must always be ended, rendered or not).
bool xr_frame_begin(void); bool xr_frame_begin(void);
// Releases the swapchain image (if one was acquired this frame) and // Releases the game quad's swapchain image (if one was acquired this
// submits it as a single XrCompositionLayerQuad positioned in front of the // frame), then does the same acquire/render/release cycle for the menu
// local reference space's origin, then ends the XR frame. // launcher and keyboard overlay quads via xr_overlay_render_and_build_layer()
// for whichever of them is currently visible, and submits whichever of the
// three quads actually rendered this frame as composition layers
// positioned in front of the local reference space's origin, then ends the
// XR frame.
void xr_frame_end(void); void xr_frame_end(void);
void xr_shutdown(void); void xr_shutdown(void);
@@ -55,6 +74,13 @@ void xr_shutdown(void);
// laser pointer always matches whatever's actually visible. // laser pointer always matches whatever's actually visible.
void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m); void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half_height_m);
// Pixel resolution of the game quad's own swapchain - i.e. game_width/
// game_height as passed to xr_init() (grd_cap->w/h, see Shock.c's
// InitSDL()) - the logical coordinate space xr_input.c needs to convert a
// game-quad ray hit's normalized u,v into engine mouse coordinates (see
// xr_mouse.h).
void xr_get_game_resolution(int *width, int *height);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif
+159
View File
@@ -0,0 +1,159 @@
#include "xr_swapchain.h"
#include <dlfcn.h>
#include <android/log.h>
#define TAG "QuestShock"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
static bool xr_check(XrInstance instance, XrResult result, const char *what) {
if (XR_SUCCEEDED(result))
return true;
char resultString[XR_MAX_RESULT_STRING_SIZE] = {0};
if (instance != XR_NULL_HANDLE)
xrResultToString(instance, result, resultString);
LOGE("XR: %s failed: %s (%d)", what, resultString[0] ? resultString : "?", (int)result);
return false;
}
// This file's other GL calls otherwise resolve to gl4es (the only GL
// symbol provider linked into this binary - see android/engine-patches/
// 02-android-opengl-es.patch), which is fine for anything shared with the
// engine's own gl4es-routed rendering. But the swapchain images OpenXR
// hands us are real driver texture objects gl4es never created itself,
// and gl4es's own glFramebufferTexture2D can't attach a texture it has no
// tracked metadata for. So the FBO *container* is created via gl4es's own
// glGenFramebuffers/glBindFramebuffer (so gl4es recognizes the id as its
// own and its own per-frame glBindFramebuffer succeeds), while the
// texture-attach step - the specifically foreign part - goes through the
// real driver directly, via dlsym against libGLESv2.so.
typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint);
typedef GLenum (*PFNQSCHECKFRAMEBUFFERSTATUS)(GLenum);
static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D;
static PFNQSCHECKFRAMEBUFFERSTATUS real_glCheckFramebufferStatus;
static bool g_real_gles_loaded = false;
static bool xr_swapchain_load_real_gles(void) {
if (g_real_gles_loaded)
return true;
void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL);
if (lib == NULL) {
LOGE("XR: swapchain dlopen(libGLESv2.so) failed: %s", dlerror());
return false;
}
real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D");
real_glCheckFramebufferStatus =
(PFNQSCHECKFRAMEBUFFERSTATUS)dlsym(lib, "glCheckFramebufferStatus");
g_real_gles_loaded = real_glFramebufferTexture2D && real_glCheckFramebufferStatus;
return g_real_gles_loaded;
}
bool xr_swapchain_create(XrInstance instance, XrSession session, int64_t format, int width,
int height, XrSwapchainState *out) {
if (!xr_swapchain_load_real_gles()) {
LOGE("XR: swapchain couldn't resolve real GLES FBO functions via dlsym");
return false;
}
out->width = width;
out->height = height;
XrSwapchainCreateInfo swapchainInfo = {XR_TYPE_SWAPCHAIN_CREATE_INFO};
swapchainInfo.usageFlags =
XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT;
swapchainInfo.format = format;
swapchainInfo.sampleCount = 1;
swapchainInfo.width = (uint32_t)width;
swapchainInfo.height = (uint32_t)height;
swapchainInfo.faceCount = 1;
swapchainInfo.arraySize = 1;
swapchainInfo.mipCount = 1;
if (!xr_check(instance, xrCreateSwapchain(session, &swapchainInfo, &out->swapchain),
"xrCreateSwapchain"))
return false;
uint32_t imageCount = 0;
xrEnumerateSwapchainImages(out->swapchain, 0, &imageCount, NULL);
if (imageCount > XR_SWAPCHAIN_MAX_IMAGES) {
LOGE("XR: swapchain reports %u images, only room for %d", imageCount,
XR_SWAPCHAIN_MAX_IMAGES);
return false;
}
// Zero-initialized, not just `.type` set per element - these structs
// also carry a `next` field the runtime may read, and an uninitialized
// stack array would leave it as garbage.
XrSwapchainImageOpenGLESKHR images[XR_SWAPCHAIN_MAX_IMAGES] = {0};
for (uint32_t i = 0; i < imageCount; i++)
images[i].type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR;
if (!xr_check(instance,
xrEnumerateSwapchainImages(out->swapchain, imageCount, &imageCount,
(XrSwapchainImageBaseHeader *)images),
"xrEnumerateSwapchainImages"))
return false;
out->image_count = imageCount;
// Wraps each swapchain-provided texture in its own framebuffer, matching
// how OpenGL.cc's own CreateFrameBuffer() wraps backupBuffer - just
// without a depth/stencil attachment, since the final composite draw
// (see opengl_swap_and_restore) never needs one. The Gen/Bind calls are
// gl4es's own (linked, not dlsym'd) - see the comment above
// real_glFramebufferTexture2D for why.
bool all_complete = true;
for (uint32_t i = 0; i < imageCount; i++) {
glGenFramebuffers(1, &out->fbos[i]);
glBindFramebuffer(GL_FRAMEBUFFER, out->fbos[i]);
real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
images[i].image, 0);
GLenum status = real_glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) {
LOGE("XR: swapchain FBO %u incomplete: 0x%x", i, status);
all_complete = false;
}
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
return all_complete;
}
void xr_swapchain_destroy(XrSwapchainState *state) {
for (uint32_t i = 0; i < state->image_count; i++) {
if (state->fbos[i] != 0)
glDeleteFramebuffers(1, &state->fbos[i]);
}
state->image_count = 0;
if (state->swapchain != XR_NULL_HANDLE)
xrDestroySwapchain(state->swapchain);
state->swapchain = XR_NULL_HANDLE;
}
// Acquires the next image of the given swapchain, waits for the runtime to
// finish with it, and binds its framebuffer (sized to exactly fill it) as
// the current render target.
bool xr_swapchain_acquire(XrInstance instance, XrSwapchainState *state) {
uint32_t imageIndex = 0;
XrSwapchainImageAcquireInfo acquireInfo = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
if (!xr_check(instance, xrAcquireSwapchainImage(state->swapchain, &acquireInfo, &imageIndex),
"xrAcquireSwapchainImage"))
return false;
XrSwapchainImageWaitInfo waitImageInfo = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};
waitImageInfo.timeout = XR_INFINITE_DURATION;
if (!xr_check(instance, xrWaitSwapchainImage(state->swapchain, &waitImageInfo),
"xrWaitSwapchainImage"))
return false;
// gl4es's own (linked, not dlsym'd) bind - this targets an FBO id gl4es
// itself created (see xr_swapchain_create()), so its own "current FBO"
// bookkeeping updates correctly and its immediate-mode draw calls
// (android_draw_surface_as_quad()) land in the right place.
glBindFramebuffer(GL_FRAMEBUFFER, state->fbos[imageIndex]);
glViewport(0, 0, state->width, state->height);
return true;
}
void xr_swapchain_release(XrInstance instance, XrSwapchainState *state) {
XrSwapchainImageReleaseInfo releaseInfo = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
xr_check(instance, xrReleaseSwapchainImage(state->swapchain, &releaseInfo),
"xrReleaseSwapchainImage");
}
+55
View File
@@ -0,0 +1,55 @@
// Generic OpenXR swapchain + per-image GL framebuffer helper, shared by
// every quad this app submits - the game quad (xr_session.c) and every
// xr_overlay.c instance (menu launcher, keyboard) - since they all need
// exactly the same setup, just at their own size/format.
#ifndef QUESTSHOCK_XR_SWAPCHAIN_H
#define QUESTSHOCK_XR_SWAPCHAIN_H
#include <stdbool.h>
#include <stdint.h>
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#include <jni.h>
#define XR_USE_PLATFORM_ANDROID 1
#define XR_USE_GRAPHICS_API_OPENGL_ES 1
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
#ifdef __cplusplus
extern "C" {
#endif
// Up to this many swapchain images/FBOs - real runtimes report small counts
// (2-4); this is just a fixed upper bound for the cache arrays below.
#define XR_SWAPCHAIN_MAX_IMAGES 8
typedef struct {
XrSwapchain swapchain;
GLuint fbos[XR_SWAPCHAIN_MAX_IMAGES];
uint32_t image_count;
int width;
int height;
} XrSwapchainState;
// Creates the swapchain and wraps each of its images in its own GL
// framebuffer, ready to bind and render into directly. instance is only
// used to log a human-readable error string on failure.
bool xr_swapchain_create(XrInstance instance, XrSession session, int64_t format, int width,
int height, XrSwapchainState *out);
void xr_swapchain_destroy(XrSwapchainState *state);
// Acquires the next image, waits for the runtime to finish with it, and
// binds its framebuffer (sized to exactly fill it) as the current render
// target.
bool xr_swapchain_acquire(XrInstance instance, XrSwapchainState *state);
void xr_swapchain_release(XrInstance instance, XrSwapchainState *state);
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,474 @@
package de.ladkau.questshock;
import android.app.Activity;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
import org.libsdl.app.SDLActivity;
/**
* The on-screen keyboard quad (see OverlayPanel for the shared off-screen-
* render/touch/cursor plumbing this builds on) - a hand-built, full
* US-layout key grid (there's no system IME to borrow once immersive) plus
* a text field echoing what's been sent. Each key press is forwarded
* straight to the game as real input (see handleKeyPress()/
* handleCharKeyPress()) - non-printable keys as an
* SDLActivity.onNativeKeyDown()/onNativeKeyUp() pair, the same one a
* physical Bluetooth keyboard's presses already drive, and printable keys
* via a synthesized SDL_TEXTINPUT event pushed from native code (see
* commitPrintableChar()/nativeSendPrintableChar() in questshock_native.c) -
* except while a Ctrl/Alt "armed" modifier is active, when they go through
* the same onNativeKeyDown()/onNativeKeyUp() path instead, so the modifier
* carries across (see toggleModifier()). Shift is a purely local layer
* toggle (uppercase/symbols vs lowercase/numbers), never itself sent to the
* game.
*
* This is a fully independent overlay quad from MenuOverlay's small
* launcher - opened via that launcher's "Keyboard" button
* (nativeShowKeyboard()) but otherwise self-contained: once open, the
* controller's menu button (which only ever affects the menu launcher, see
* xr_input.c) doesn't hide it, so it stays up as a standing input panel
* while actually playing rather than a modal you open and close. The only
* way to hide it is its own title bar's Close button (buildTitleBar()),
* which also doubles as a drag handle for repositioning it (handled
* entirely on the native side - see xr_input.c - since it never reaches
* this class's own touch dispatch). contentAlpha() renders the whole panel
* semi-transparent so whatever's behind it stays visible while it's up.
*/
public class KeyboardOverlay extends OverlayPanel {
// Matches xr_session.c's KEYBOARD_WIDTH/KEYBOARD_HEIGHT swapchain size -
// fixed, not tied to any real display metric, since this tree is never
// shown in a real window.
private static final int WIDTH = 1024;
private static final int HEIGHT = 768;
private static final int TYPED_TEXT_HEIGHT = 100;
// Height of the title bar (buildTitleBar()) and width of its Close
// button - xr_input.c's TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION
// must be kept in sync with these (as fractions of WIDTH/HEIGHT above),
// since the drag-handle-vs-Close-button-vs-rest-of-the-keyboard
// decision happens entirely on the native side, before a hit ever
// reaches this class's own touch dispatch.
private static final int TITLE_BAR_HEIGHT = 90;
private static final int CLOSE_BUTTON_WIDTH = 160;
// ~70% opaque - readable but lets whatever's behind the panel (the game
// quad, or empty space) show through while it's left up during play.
private static final int PANEL_ALPHA = 180;
private static KeyboardOverlay instance;
private TextView typedTextView;
private final StringBuilder typedText = new StringBuilder();
// Shift is a local-only layer toggle (never itself sent to the game -
// see updateShiftVisual()); Ctrl/Alt are one-shot "armed" modifiers,
// sent to the game the moment they're pressed and released again as
// soon as the next key consumes them (see toggleModifier()/
// releaseArmedModifiers()) - there's no press-and-hold gesture with a
// laser pointer + trigger click, so armed-then-consumed stands in for
// holding the key down.
private boolean shiftActive = false;
private boolean ctrlArmed = false;
private boolean altArmed = false;
private Button shiftButton;
private Button ctrlButton;
private Button altButton;
// Every CharKey created by addRow(), so updateShiftVisual() can relabel
// all of them at once when Shift toggles. Assigned (not a field
// initializer) at the top of buildContent() - see OverlayPanel's
// constructor note on why a field a subclass's buildContent() depends
// on can't rely on normal field-initializer timing.
private List<CharKey> charKeys;
// A key whose printed character (and, while unmodified, whose game
// input) depends on the Shift layer - covers letters, digits, and
// punctuation uniformly (e.g. 'q'/'Q', '1'/'!', '-'/'_'). androidKeyCode
// is only used for the Ctrl/Alt-combo path (see handleCharKeyPress()) -
// the unmodified path goes through commitPrintableChar() instead, which
// doesn't need an Android keycode at all.
private static final class CharKey {
final char base;
final char shifted;
final int androidKeyCode;
Button button;
CharKey(char base, char shifted, int androidKeyCode) {
this.base = base;
this.shifted = shifted;
this.androidKeyCode = androidKeyCode;
}
}
private static CharKey charKey(char base, char shifted, int androidKeyCode) {
return new CharKey(base, shifted, androidKeyCode);
}
// Space is a CharKey like any other (see addRow()), but a literal
// space character makes for an unreadable, blank-looking button - keep
// showing the word "Space" instead, same as before it became a CharKey.
private static String charKeyLabel(CharKey key, boolean shiftActive) {
if (key.base == ' ') {
return "Space";
}
return String.valueOf(shiftActive ? key.shifted : key.base);
}
private KeyboardOverlay(Activity activity) {
super(activity, WIDTH, HEIGHT);
}
@Override
protected int contentAlpha() {
return PANEL_ALPHA;
}
@Override
protected View buildContent() {
charKeys = new ArrayList<>();
LinearLayout panel = new LinearLayout(activity);
panel.setOrientation(LinearLayout.VERTICAL);
panel.setBackgroundColor(0xFF202020);
panel.addView(buildTitleBar(),
new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, TITLE_BAR_HEIGHT));
typedTextView = new TextView(activity);
typedTextView.setTextColor(0xFFFFFFFF);
typedTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 28);
typedTextView.setBackgroundColor(0xFF303030);
typedTextView.setPadding(20, 20, 20, 20);
panel.addView(typedTextView, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, TYPED_TEXT_HEIGHT));
// Rows follow real keyboard geometry where it helps (Tab starting
// the QWERTY row, Enter ending the home row, Shift starting the
// bottom-letter row, a compact arrow cluster bottom-right) so the
// layout reads as a familiar keyboard rather than an arbitrary grid.
addRow(panel, "Esc", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10",
"F11", "F12", "Back");
addRow(panel,
charKey('`', '~', KeyEvent.KEYCODE_GRAVE),
charKey('1', '!', KeyEvent.KEYCODE_1),
charKey('2', '@', KeyEvent.KEYCODE_2),
charKey('3', '#', KeyEvent.KEYCODE_3),
charKey('4', '$', KeyEvent.KEYCODE_4),
charKey('5', '%', KeyEvent.KEYCODE_5),
charKey('6', '^', KeyEvent.KEYCODE_6),
charKey('7', '&', KeyEvent.KEYCODE_7),
charKey('8', '*', KeyEvent.KEYCODE_8),
charKey('9', '(', KeyEvent.KEYCODE_9),
charKey('0', ')', KeyEvent.KEYCODE_0),
charKey('-', '_', KeyEvent.KEYCODE_MINUS),
charKey('=', '+', KeyEvent.KEYCODE_EQUALS));
addRow(panel, "Tab",
charKey('q', 'Q', KeyEvent.KEYCODE_Q), charKey('w', 'W', KeyEvent.KEYCODE_W),
charKey('e', 'E', KeyEvent.KEYCODE_E), charKey('r', 'R', KeyEvent.KEYCODE_R),
charKey('t', 'T', KeyEvent.KEYCODE_T), charKey('y', 'Y', KeyEvent.KEYCODE_Y),
charKey('u', 'U', KeyEvent.KEYCODE_U), charKey('i', 'I', KeyEvent.KEYCODE_I),
charKey('o', 'O', KeyEvent.KEYCODE_O), charKey('p', 'P', KeyEvent.KEYCODE_P),
charKey('[', '{', KeyEvent.KEYCODE_LEFT_BRACKET),
charKey(']', '}', KeyEvent.KEYCODE_RIGHT_BRACKET),
charKey('\\', '|', KeyEvent.KEYCODE_BACKSLASH));
addRow(panel, "Ctrl",
charKey('a', 'A', KeyEvent.KEYCODE_A), charKey('s', 'S', KeyEvent.KEYCODE_S),
charKey('d', 'D', KeyEvent.KEYCODE_D), charKey('f', 'F', KeyEvent.KEYCODE_F),
charKey('g', 'G', KeyEvent.KEYCODE_G), charKey('h', 'H', KeyEvent.KEYCODE_H),
charKey('j', 'J', KeyEvent.KEYCODE_J), charKey('k', 'K', KeyEvent.KEYCODE_K),
charKey('l', 'L', KeyEvent.KEYCODE_L),
charKey(';', ':', KeyEvent.KEYCODE_SEMICOLON),
charKey('\'', '"', KeyEvent.KEYCODE_APOSTROPHE),
"Enter");
addRow(panel, "Shift",
charKey('z', 'Z', KeyEvent.KEYCODE_Z), charKey('x', 'X', KeyEvent.KEYCODE_X),
charKey('c', 'C', KeyEvent.KEYCODE_C), charKey('v', 'V', KeyEvent.KEYCODE_V),
charKey('b', 'B', KeyEvent.KEYCODE_B), charKey('n', 'N', KeyEvent.KEYCODE_N),
charKey('m', 'M', KeyEvent.KEYCODE_M),
charKey(',', '<', KeyEvent.KEYCODE_COMMA),
charKey('.', '>', KeyEvent.KEYCODE_PERIOD),
charKey('/', '?', KeyEvent.KEYCODE_SLASH),
"Up");
addRow(panel, "Alt", charKey(' ', ' ', KeyEvent.KEYCODE_SPACE), "Left", "Down", "Right");
return panel;
}
// The title bar: a decorative "drag here" label (not itself
// interactive - see the class doc, dragging is handled entirely by
// xr_input.c before a hit ever reaches here) plus a real Close button,
// which is dispatched as an ordinary click like any other key. Its
// height and the Close button's width must stay in sync with
// xr_input.c's TITLE_BAR_V_FRACTION/CLOSE_BUTTON_U_FRACTION.
private LinearLayout buildTitleBar() {
LinearLayout bar = new LinearLayout(activity);
bar.setOrientation(LinearLayout.HORIZONTAL);
bar.setBackgroundColor(0xFF3A3A3A);
TextView label = new TextView(activity);
label.setText("Keyboard - drag here to move");
label.setTextColor(0xFFFFFFFF);
label.setTextSize(TypedValue.COMPLEX_UNIT_SP, 22);
label.setGravity(Gravity.CENTER_VERTICAL);
label.setPadding(24, 0, 0, 0);
bar.addView(label, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
Button close = new Button(activity);
close.setText("Close");
close.setOnClickListener(v -> handleKeyPress("Close"));
close.setOnTouchListener(CLICK_ON_TOUCH_UP);
bar.addView(close,
new LinearLayout.LayoutParams(CLOSE_BUTTON_WIDTH, ViewGroup.LayoutParams.MATCH_PARENT));
return bar;
}
// Each row fills the panel's remaining height evenly (weight 1 on the
// row itself). Accepts a mix of String (a fixed-action control key,
// handled by handleKeyPress()) and CharKey (a Shift-sensitive character
// key, handled by handleCharKeyPress()) per item, so a row can match
// real keyboard geometry (e.g. "Tab" followed by a run of CharKeys).
// Within a row, CharKeys and most control keys share width equally
// except Space (wider, like a real keyboard) and Esc/Back/Enter/Tab/
// Shift/Ctrl/Alt (narrower, see keyWeight()).
private void addRow(LinearLayout panel, Object... items) {
LinearLayout row = new LinearLayout(activity);
row.setOrientation(LinearLayout.HORIZONTAL);
for (Object item : items) {
Button key = new Button(activity);
key.setOnTouchListener(CLICK_ON_TOUCH_UP);
float weight;
if (item instanceof CharKey) {
CharKey charKey = (CharKey) item;
charKey.button = key;
key.setText(charKeyLabel(charKey, shiftActive));
key.setOnClickListener(v -> handleCharKeyPress(charKey));
charKeys.add(charKey);
// Space is a CharKey too (base == shifted == ' '), but keeps
// its traditional wide key like the other control keys do.
weight = (charKey.base == ' ') ? 4f : 1f;
} else {
String label = (String) item;
key.setText(label);
key.setOnClickListener(v -> handleKeyPress(label));
weight = keyWeight(label);
if (label.equals("Shift")) {
shiftButton = key;
} else if (label.equals("Ctrl")) {
ctrlButton = key;
} else if (label.equals("Alt")) {
altButton = key;
}
}
row.addView(key, new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.MATCH_PARENT, weight));
}
panel.addView(row, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
}
private static float keyWeight(String label) {
if (label.equals("Back") || label.equals("Esc") || label.equals("Enter")
|| label.equals("Tab") || label.equals("Shift") || label.equals("Ctrl")
|| label.equals("Alt")) {
return 1.5f;
}
return 1f;
}
// Maps a control key's label to the Android keycode forwarded via
// SDLActivity.onNativeKeyDown()/onNativeKeyUp() - the same pair a
// physical Bluetooth keyboard's key events already drive (see
// SDLActivity.handleKeyEvent()). F-keys are contiguous in
// android.view.KeyEvent (KEYCODE_F1..KEYCODE_F12), so "F1".."F12" are
// computed rather than listed individually.
private static int controlKeyCode(String label) {
switch (label) {
case "Back": return KeyEvent.KEYCODE_DEL;
case "Esc": return KeyEvent.KEYCODE_ESCAPE;
case "Enter": return KeyEvent.KEYCODE_ENTER;
case "Tab": return KeyEvent.KEYCODE_TAB;
case "Up": return KeyEvent.KEYCODE_DPAD_UP;
case "Down": return KeyEvent.KEYCODE_DPAD_DOWN;
case "Left": return KeyEvent.KEYCODE_DPAD_LEFT;
case "Right": return KeyEvent.KEYCODE_DPAD_RIGHT;
default:
if (label.charAt(0) == 'F') {
int n = Integer.parseInt(label.substring(1));
return KeyEvent.KEYCODE_F1 + (n - 1);
}
throw new IllegalArgumentException("no keycode for " + label);
}
}
// "Close" (the title bar's button - see buildTitleBar()) hides this
// overlay's quad entirely (see nativeRequestClose()) and never reaches
// the game. Shift/Ctrl/Alt are handled separately below (Shift is a
// local layer toggle; Ctrl/Alt are one-shot armed modifiers) since
// neither sends a plain key event of its own the way every other
// control key here does. Everything else (Back/Esc/Enter/Tab/arrows/
// F1-F12) is non-printable, so it's forwarded as a real Android key
// event via controlKeyCode() above, then releases any armed Ctrl/Alt
// modifier (this key just consumed it). This all matters beyond just
// the visible typedTextView below: the engine's cutscene skip handler
// (cutsloop.c's cutscene_key_handler()) only reacts to Esc/Enter/Space,
// so those need to reach the game as actual game input, not just be
// echoed locally.
private void handleKeyPress(String label) {
if (label.equals("Close")) {
nativeRequestClose();
return;
}
if (label.equals("Shift")) {
shiftActive = !shiftActive;
updateShiftVisual();
return;
}
if (label.equals("Ctrl") || label.equals("Alt")) {
toggleModifier(label);
return;
}
int keyCode = controlKeyCode(label);
SDLActivity.onNativeKeyDown(keyCode);
SDLActivity.onNativeKeyUp(keyCode);
releaseArmedModifiers();
if (label.equals("Back")) {
if (typedText.length() > 0) {
typedText.setLength(typedText.length() - 1);
}
} else {
typedText.append('[').append(label).append(']');
}
typedTextView.setText(typedText.toString());
}
// A CharKey's printed character depends only on the Shift layer
// (updateShiftVisual() keeps its button's label in sync). Which game
// input it produces depends on whether a modifier is armed: unmodified,
// it goes through commitPrintableChar() below like a normal typed
// character; with Ctrl/Alt armed, it instead goes through the same
// onNativeKeyDown()/onNativeKeyUp() path handleKeyPress() uses for
// control keys, since that's the only path that carries
// ev.key.keysym.mod through to the engine (sdl_events.c's pump_events()
// reads Ctrl/Alt only off that path, never off SDL_TEXTINPUT).
private void handleCharKeyPress(CharKey key) {
char c = shiftActive ? key.shifted : key.base;
if (ctrlArmed || altArmed) {
SDLActivity.onNativeKeyDown(key.androidKeyCode);
SDLActivity.onNativeKeyUp(key.androidKeyCode);
releaseArmedModifiers();
} else {
commitPrintableChar(c);
}
typedText.append(c);
typedTextView.setText(typedText.toString());
}
// Ctrl/Alt are "armed" rather than held: pressing one immediately sends
// its keydown (so SDL's own modifier tracking picks it up for whatever
// key comes next - see handleCharKeyPress()/handleKeyPress()) and
// brackets its label for feedback; pressing the same key again before
// it's been used cancels it (sends the matching keyup, un-brackets).
// The normal case - actually being consumed by the next key press - is
// handled by releaseArmedModifiers() below, not here.
private void toggleModifier(String label) {
boolean ctrl = label.equals("Ctrl");
Button button = ctrl ? ctrlButton : altButton;
int keyCode = ctrl ? KeyEvent.KEYCODE_CTRL_LEFT : KeyEvent.KEYCODE_ALT_LEFT;
boolean nowArmed = ctrl ? !ctrlArmed : !altArmed;
if (ctrl) {
ctrlArmed = nowArmed;
} else {
altArmed = nowArmed;
}
if (nowArmed) {
SDLActivity.onNativeKeyDown(keyCode);
} else {
SDLActivity.onNativeKeyUp(keyCode);
}
button.setText(nowArmed ? "[" + label + "]" : label);
}
// Called after any key actually reaches the game (a control key in
// handleKeyPress(), or a CharKey in handleCharKeyPress()) so an armed
// Ctrl/Alt only ever applies to the very next key, then releases -
// matching a real Ctrl/Alt+key combo's keyup once the combo is done.
private void releaseArmedModifiers() {
if (ctrlArmed) {
SDLActivity.onNativeKeyUp(KeyEvent.KEYCODE_CTRL_LEFT);
ctrlArmed = false;
ctrlButton.setText("Ctrl");
}
if (altArmed) {
SDLActivity.onNativeKeyUp(KeyEvent.KEYCODE_ALT_LEFT);
altArmed = false;
altButton.setText("Alt");
}
}
// Shift never itself reaches the game (see class doc) - it only flips
// which character each CharKey shows/sends, including its own label.
// Note this means a Ctrl+Shift+key chord won't carry Shift to the
// engine (Ctrl/Alt are real forwarded modifier keys, Shift here isn't) -
// not worth solving unless it actually comes up.
private void updateShiftVisual() {
shiftButton.setText(shiftActive ? "[Shift]" : "Shift");
for (CharKey key : charKeys) {
key.button.setText(charKeyLabel(key, shiftActive));
}
}
// Synthesizes the SDL_TEXTINPUT event pump_events() (sdl_events.c)
// requires for printable characters (see handleCharKeyPress() above),
// by building and pushing it directly in native code (see
// nativeSendPrintableChar() in questshock_native.c) rather than through
// Android's IME (there's no real IME session behind this off-screen,
// never-attached grid for that plumbing to hook into).
private static void commitPrintableChar(char c) {
nativeSendPrintableChar(c);
}
private static native void nativeSendPrintableChar(char c);
// Hides this overlay's quad (xr_overlay_set_visible(..., false)) - the
// only way to do so, since the controller's menu button doesn't affect
// the keyboard (see this class's doc comment and xr_input.c).
private static native void nativeRequestClose();
// Called from xr_overlay.c's xr_overlay_init_jni(), on the render
// thread, right after it resolves this class via the activity's own
// ClassLoader (plain FindClass() can't see app classes from a thread
// that was attached to the JVM rather than spawned from Java).
public static void nativeInit(Activity activity) {
if (instance == null) {
instance = new KeyboardOverlay(activity);
}
}
public static byte[] nativeTakePixelsIfDirty() {
return instance == null ? null : instance.takePixelsIfDirty();
}
public static void nativeDispatchTouch(final float u, final float v, final boolean down) {
if (instance != null) {
instance.dispatchTouch(u, v, down);
}
}
public static void nativeUpdateCursor(final float u, final float v, final boolean visible) {
if (instance != null) {
instance.dispatchUpdateCursor(u, v, visible);
}
}
}
@@ -1,132 +1,76 @@
package de.ladkau.questshock; package de.ladkau.questshock;
import android.app.Activity; import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.os.SystemClock;
import android.util.Log;
import android.view.Gravity; import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View; import android.view.View;
import android.widget.Button; import android.widget.Button;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import java.nio.ByteBuffer;
/** /**
* An off-screen, never-attached-to-a-window View tree for questshock's * The small main menu launcher quad (see OverlayPanel for the shared off-
* OpenXR menu quad (see android/app/src/main/cpp/xr_menu.c, which owns the * screen-render/touch/cursor plumbing this builds on) - just a "Keyboard"
* quad's swapchain and drives this class entirely via JNI - construction, * button for now. Toggled by the controller's menu button (see
* touch input, and pixel readback). Rendering to a Bitmap and dispatching * xr_input.c); its "Keyboard" click only opens the (fully independent)
* synthetic MotionEvents into an unattached hierarchy both work the same * keyboard overlay quad via nativeShowKeyboard() - it never touches this
* way they would for an attached View - draw(Canvas)/dispatchTouchEvent() * panel's own visibility, so both can be shown together (see
* don't require a ViewRootImpl/window, just a measured+laid-out tree. * KeyboardOverlay for the keyboard itself).
*/ */
public class MenuOverlay { public class MenuOverlay extends OverlayPanel {
private static final String TAG = "QuestShock"; // Matches xr_session.c's MENU_WIDTH/MENU_HEIGHT swapchain size - fixed,
// Matches xr_menu.c's MENU_WIDTH/MENU_HEIGHT swapchain size - fixed,
// not tied to any real display metric, since this tree is never shown // not tied to any real display metric, since this tree is never shown
// in a real window. // in a real window.
static final int WIDTH = 1024; private static final int WIDTH = 512;
static final int HEIGHT = 768; private static final int HEIGHT = 256;
private static MenuOverlay instance; private static MenuOverlay instance;
private final Activity activity;
private final FrameLayout root;
private final Button keyboardButton;
private final Bitmap bitmap;
private final Canvas canvas;
private final Object pixelLock = new Object();
private byte[] pendingPixels;
private MenuOverlay(Activity activity) { private MenuOverlay(Activity activity) {
this.activity = activity; super(activity, WIDTH, HEIGHT);
root = new FrameLayout(activity);
root.setBackgroundColor(0xFF202020);
keyboardButton = new Button(activity);
keyboardButton.setText("Keyboard");
keyboardButton.setOnClickListener(v -> Log.i(TAG, "MenuOverlay: Keyboard button clicked"));
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(420, 140);
lp.gravity = Gravity.CENTER;
root.addView(keyboardButton, lp);
bitmap = Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888);
canvas = new Canvas(bitmap);
int widthSpec = View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY);
root.measure(widthSpec, heightSpec);
root.layout(0, 0, WIDTH, HEIGHT);
forceRedraw();
} }
// Called from xr_menu.c's xr_menu_init(), on the render thread, right @Override
// after it resolves this class via the activity's own ClassLoader protected View buildContent() {
// (plain FindClass() can't see app classes from a thread that was FrameLayout panel = new FrameLayout(activity);
// attached to the JVM rather than spawned from Java - see that file's panel.setBackgroundColor(0xFF202020);
// xr_menu_find_class()). Button keyboardButton = new Button(activity);
keyboardButton.setText("Keyboard");
keyboardButton.setOnClickListener(v -> nativeShowKeyboard());
keyboardButton.setOnTouchListener(CLICK_ON_TOUCH_UP);
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(300, 100);
lp.gravity = Gravity.CENTER;
panel.addView(keyboardButton, lp);
return panel;
}
// Opens the keyboard overlay quad (questshock_native.c ->
// xr_overlay_set_visible(xr_session_get_keyboard_overlay(), true)) -
// see KeyboardOverlay for the panel itself and its own Close button,
// the only way to hide it again.
private static native void nativeShowKeyboard();
// Called from xr_overlay.c's xr_overlay_init_jni(), on the render
// thread, right after it resolves this class via the activity's own
// ClassLoader (plain FindClass() can't see app classes from a thread
// that was attached to the JVM rather than spawned from Java).
public static void nativeInit(Activity activity) { public static void nativeInit(Activity activity) {
if (instance == null) { if (instance == null) {
instance = new MenuOverlay(activity); instance = new MenuOverlay(activity);
} }
} }
// Polled once per frame while the menu quad is visible (see
// xr_menu_render_if_visible()) - returns the current pixels only once
// per redraw (null otherwise), so the native side knows when it can
// skip re-uploading a texture that hasn't actually changed.
public static byte[] nativeTakePixelsIfDirty() { public static byte[] nativeTakePixelsIfDirty() {
if (instance == null) { return instance == null ? null : instance.takePixelsIfDirty();
return null;
}
synchronized (instance.pixelLock) {
byte[] pixels = instance.pendingPixels;
instance.pendingPixels = null;
return pixels;
}
} }
// u/v are the menu quad's own hit-test coordinates (0..1, top-left
// origin) - computed by xr_input.c's ray/quad intersection against
// xr_menu.c's reported quad geometry, forwarded here as a synthetic
// tap. Runs on the UI thread since the View/Bitmap/Canvas objects here
// are otherwise only ever touched from there.
public static void nativeDispatchTouch(final float u, final float v, final boolean down) { public static void nativeDispatchTouch(final float u, final float v, final boolean down) {
if (instance == null) { if (instance != null) {
return; instance.dispatchTouch(u, v, down);
} }
instance.activity.runOnUiThread(() -> instance.handleTouch(u, v, down));
} }
private void handleTouch(float u, float v, boolean down) { public static void nativeUpdateCursor(final float u, final float v, final boolean visible) {
float x = u * WIDTH; if (instance != null) {
float y = v * HEIGHT; instance.dispatchUpdateCursor(u, v, visible);
long time = SystemClock.uptimeMillis();
MotionEvent event = MotionEvent.obtain(
time, time, down ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, x, y, 0);
try {
root.dispatchTouchEvent(event);
} finally {
event.recycle();
}
forceRedraw();
}
private void forceRedraw() {
canvas.drawColor(0xFF202020);
root.draw(canvas);
byte[] pixels = new byte[WIDTH * HEIGHT * 4];
// ARGB_8888's actual in-memory byte order is R,G,B,A - matches
// GL_RGBA/GL_UNSIGNED_BYTE on the native side with no swizzling.
bitmap.copyPixelsToBuffer(ByteBuffer.wrap(pixels));
synchronized (pixelLock) {
pendingPixels = pixels;
} }
} }
} }
@@ -0,0 +1,237 @@
package de.ladkau.questshock;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.View;
import java.nio.ByteBuffer;
/**
* Shared machinery for an off-screen, never-attached-to-a-window View tree
* rendered into its own OpenXR quad (see android/app/src/main/cpp/
* xr_overlay.c, which owns the quad's swapchain and drives whichever
* concrete subclass via JNI - construction, touch input, and pixel
* readback). Rendering to a Bitmap and dispatching synthetic MotionEvents
* into an unattached hierarchy both work the same way they would for an
* attached View - draw(Canvas)/dispatchTouchEvent() don't require a
* ViewRootImpl/window, just a measured+laid-out tree.
*
* Subclasses (MenuOverlay, KeyboardOverlay) provide their own content via
* buildContent() and, if they want translucency, contentAlpha() - and, since
* Java has no static virtual dispatch and each overlay needs its own
* singleton, each subclass still declares its own thin nativeInit()/
* nativeTakePixelsIfDirty()/nativeDispatchTouch()/nativeUpdateCursor()
* static methods (matching what xr_overlay.c's JNI glue resolves by class
* name) that just delegate into the instance methods here
* (takePixelsIfDirty()/dispatchTouch()/dispatchUpdateCursor()).
*/
abstract class OverlayPanel {
private static final float CURSOR_RADIUS = 10f;
// Button's own click handling (View.onTouchEvent()'s ACTION_UP case)
// calls View.post(mPerformClick) rather than invoking performClick()
// directly; post() queues the runnable to run once the view is attached
// to a window and returns true immediately even when unattached, so on
// this permanently-unattached tree that queued click silently never
// fires - dispatchTouchEvent() still delivers the down/up events
// correctly, only the click callback is swallowed. Registering this as
// each button's OnTouchListener bypasses that path entirely: returning
// true here skips View's internal onTouchEvent() (see
// ViewGroup/View#dispatchTouchEvent), so performClick() (which still
// runs any OnClickListener set via setOnClickListener()) is called
// directly instead.
protected static final View.OnTouchListener CLICK_ON_TOUCH_UP = (v, event) -> {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
v.setPressed(true);
break;
case MotionEvent.ACTION_UP:
v.setPressed(false);
v.performClick();
break;
case MotionEvent.ACTION_CANCEL:
v.setPressed(false);
break;
}
return true;
};
protected final Activity activity;
private final int width;
private final int height;
private final View root;
// contentBitmap holds just the panel's own rendered content (no cursor),
// redrawn only when that content actually changes (redrawContent(), via
// handleTouch() or a subclass's own key handling) via root.draw() - a
// full off-screen View-tree traversal that gets noticeably more
// expensive on a busier content tree. bitmap is what's actually
// published to native (see takePixelsIfDirty()): compositeAndPublish()
// cheaply blits contentBitmap plus the cursor circle into it. Splitting
// these two apart matters because updateCursor() below fires every
// single frame (~90Hz) while aiming at the panel - if it called the
// expensive root.draw() path every time (as an earlier version did), a
// busy content tree made each redraw slow enough that runOnUiThread()
// posts piled up faster than the UI thread could drain them, so the
// cursor visibly lagged behind the controller's actual aim instead of
// tracking it.
private final Bitmap contentBitmap;
private final Canvas contentCanvas;
private final Bitmap bitmap;
private final Canvas canvas;
private final Paint cursorPaint;
// Draws contentBitmap at contentAlpha() - see compositeAndPublish().
private final Paint contentAlphaPaint;
private final Object pixelLock = new Object();
private byte[] pendingPixels;
// Only ever touched on the UI thread (both dispatchUpdateCursor() and
// compositeAndPublish() run/are posted there) - no lock needed, unlike
// pendingPixels above.
private boolean cursorVisible = false;
private float cursorX = 0f;
private float cursorY = 0f;
protected OverlayPanel(Activity activity, int width, int height) {
this.activity = activity;
this.width = width;
this.height = height;
root = buildContent();
int widthSpec = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY);
root.measure(widthSpec, heightSpec);
root.layout(0, 0, width, height);
contentBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
contentCanvas = new Canvas(contentBitmap);
bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
canvas = new Canvas(bitmap);
cursorPaint = new Paint();
cursorPaint.setColor(0xFFFFFFFF);
cursorPaint.setAntiAlias(true);
// contentAlpha() must not depend on any subclass instance state -
// it's called here, from the superclass constructor, before the
// subclass's own field initializers/constructor body have run (the
// usual Java construction order: this class's fields/constructor
// first, then the subclass's). A static constant is the only thing
// that's safe to return.
contentAlphaPaint = new Paint();
contentAlphaPaint.setAlpha(contentAlpha());
redrawContent();
}
// Builds this overlay's content View tree - called once, from this
// class's own constructor (see the note on contentAlpha() above: don't
// rely on subclass instance fields being initialized yet here; if a
// subclass needs its own mutable state available while building its
// content, e.g. a list of buttons to later relabel, initialize that
// field's value at the top of this method instead of via a field
// initializer).
protected abstract View buildContent();
// 0-255; defaults to fully opaque. Override for translucency (see the
// constructor's note on what's safe to depend on here).
protected int contentAlpha() {
return 255;
}
// Polled once per frame while this overlay's quad is visible (see
// xr_overlay_render_and_build_layer()) - returns the current pixels
// only once per redraw (null otherwise), so the native side knows when
// it can skip re-uploading a texture that hasn't actually changed.
protected final byte[] takePixelsIfDirty() {
synchronized (pixelLock) {
byte[] pixels = pendingPixels;
pendingPixels = null;
return pixels;
}
}
// u/v are this quad's own hit-test coordinates (0..1, top-left origin)
// - computed by xr_input.c's ray/quad intersection against xr_overlay.c's
// reported quad geometry, forwarded here as a synthetic tap. Runs on the
// UI thread since the View/Bitmap/Canvas objects here are otherwise only
// ever touched from there.
protected final void dispatchTouch(final float u, final float v, final boolean down) {
activity.runOnUiThread(() -> handleTouch(u, v, down));
}
// u/v are the same hit-test coordinates dispatchTouch() uses, but
// polled once per frame regardless of click state rather than only on
// click edges - lets the cursor track the aim ray continuously instead
// of only jumping when a trigger is pressed. visible=false (no hand's
// ray currently on the quad) hides it.
protected final void dispatchUpdateCursor(final float u, final float v,
final boolean visible) {
activity.runOnUiThread(() -> updateCursor(u, v, visible));
}
private void updateCursor(float u, float v, boolean visible) {
float x = u * width;
float y = v * height;
if (visible == cursorVisible && x == cursorX && y == cursorY) {
return;
}
cursorVisible = visible;
cursorX = x;
cursorY = y;
compositeAndPublish();
}
private void handleTouch(float u, float v, boolean down) {
float x = u * width;
float y = v * height;
long time = SystemClock.uptimeMillis();
MotionEvent event = MotionEvent.obtain(
time, time, down ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, x, y, 0);
try {
root.dispatchTouchEvent(event);
} finally {
event.recycle();
}
redrawContent();
}
// Re-runs the full off-screen View-tree draw (expensive - see the
// contentBitmap field comment above) - call only when the panel's
// actual content changed, not for the cursor-only updates
// compositeAndPublish() below handles on its own.
protected final void redrawContent() {
contentCanvas.drawColor(0xFF202020);
root.draw(contentCanvas);
compositeAndPublish();
}
// Cheap per-frame path: blits the last-rendered contentBitmap (no View
// traversal) plus the cursor circle into bitmap and publishes it.
// contentBitmap itself is fully opaque (redrawContent() draws an opaque
// background first); contentAlpha() translucency is applied right here
// instead, via contentAlphaPaint - which needs bitmap cleared to fully
// transparent first, since otherwise each frame's alpha-blended draw
// would blend against whatever was left over in bitmap from the
// previous frame rather than against nothing, compounding into a
// ghosting trail across frames.
private void compositeAndPublish() {
bitmap.eraseColor(0);
canvas.drawBitmap(contentBitmap, 0, 0, contentAlphaPaint);
if (cursorVisible) {
canvas.drawCircle(cursorX, cursorY, CURSOR_RADIUS, cursorPaint);
}
byte[] pixels = new byte[width * height * 4];
// ARGB_8888's actual in-memory byte order is R,G,B,A - matches
// GL_RGBA/GL_UNSIGNED_BYTE on the native side with no swizzling.
bitmap.copyPixelsToBuffer(ByteBuffer.wrap(pixels));
synchronized (pixelLock) {
pendingPixels = pixels;
}
}
}
+113 -3
View File
@@ -18,6 +18,13 @@ ARG FLUIDSYNTH_LITE_REF=c539a8d9270ba5a3f7d6e460606483fc2ab1eb61
# Soundfont used for MIDI music, matching what engine/build_deps.sh itself # Soundfont used for MIDI music, matching what engine/build_deps.sh itself
# fetches (a free substitute for the Windows default GM soundfont). # fetches (a free substitute for the Windows default GM soundfont).
ARG SOUNDFONT_URL=http://rancid.kapsi.fi/windows.sf2 ARG SOUNDFONT_URL=http://rancid.kapsi.fi/windows.sf2
# GLEW, for the Windows/MinGW cross-build only (engine/CMakeLists.txt's
# WIN32 branch - Windows' own opengl32.dll only exposes OpenGL 1.1, so
# anything newer needs GLEW's runtime extension loading; Linux instead
# gets modern prototypes straight from Mesa's headers, no loader needed -
# see engine/src/MacSrc/OpenGL.cc). Matches the version engine/'s own
# upstream Windows build script (build_win64.sh) used.
ARG GLEW_VERSION=2.1.0
# Gitea/GitHub Actions' JS-based actions (actions/checkout, # Gitea/GitHub Actions' JS-based actions (actions/checkout,
# actions/upload-artifact, ...) need a node binary in the container job's # actions/upload-artifact, ...) need a node binary in the container job's
# PATH - this image is otherwise pure C toolchain, so it isn't pulled in # PATH - this image is otherwise pure C toolchain, so it isn't pulled in
@@ -99,6 +106,15 @@ ENV DEBIAN_FRONTEND=noninteractive
# openssh-client: the CI workflow's `sftp` publish step. # openssh-client: the CI workflow's `sftp` publish step.
# openjdk-17-jdk-headless: Gradle/AGP's own minimum JDK for the APK build. # openjdk-17-jdk-headless: Gradle/AGP's own minimum JDK for the APK build.
# unzip: extracts the Android cmdline-tools zip below. # unzip: extracts the Android cmdline-tools zip below.
# mingw-w64: the x86_64-w64-mingw32-{gcc,g++,windres,ar,...} cross
# toolchain for the Windows desktop build (see the "Windows cross-compile"
# section below). Ubuntu ships both a win32-thread-model and a
# posix-thread-model variant behind update-alternatives; the default
# (win32) is fine here since nothing in engine/ uses std::thread.
# zip: `make package-win`'s Windows release archive - a CI job's own
# `make package` runs inside this image as its container (see
# .gitea/workflows/build.yml), so it needs to be baked in here, not just
# available on a local dev machine's own host (see README).
# #
# All apt installs deliberately live in this one RUN, first, so editing # All apt installs deliberately live in this one RUN, first, so editing
# anything below it (in particular the Android cross-compile steps, the # anything below it (in particular the Android cross-compile steps, the
@@ -111,7 +127,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1-mesa-dev libglx-dev libxext-dev libx11-dev libxrandr-dev \ libgl1-mesa-dev libglx-dev libxext-dev libx11-dev libxrandr-dev \
libxi-dev libxfixes-dev libxss-dev libxinerama-dev libxcursor-dev \ libxi-dev libxfixes-dev libxss-dev libxinerama-dev libxcursor-dev \
libogg-dev libvorbis-dev libasound2-dev openssh-client \ libogg-dev libvorbis-dev libasound2-dev openssh-client \
openjdk-17-jdk-headless unzip \ openjdk-17-jdk-headless unzip mingw-w64 zip \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /opt/prebuilt WORKDIR /opt/prebuilt
@@ -150,10 +166,96 @@ RUN git clone https://github.com/EtherTyper/fluidsynth-lite.git \
&& rm -rf .git && rm -rf .git
# General MIDI soundfont for fluidsynth playback - engine/build_deps.sh # General MIDI soundfont for fluidsynth playback - engine/build_deps.sh
# fetches the same file and drops it into engine/res/. # fetches the same file and drops it into engine/res/. Shared by both the
# Linux and Windows builds below.
RUN mkdir -p soundfont \ RUN mkdir -p soundfont \
&& curl -sSL -o soundfont/default.sf2 "${SOUNDFONT_URL}" && curl -sSL -o soundfont/default.sf2 "${SOUNDFONT_URL}"
# CMake toolchain file for the Windows/MinGW cross-build below, reused at
# container-run time by build-image/build-engine-win.sh (see
# MINGW_TOOLCHAIN_FILE) to cross-compile engine/ itself the same way. GCC's
# runtime (libgcc/libstdc++) is linked statically so only the SDL2/
# SDL2_mixer/GLEW/fluidsynth-lite DLLs (plus libwinpthread, which isn't
# safe to static-link the same way) need shipping alongside systemshock.exe.
ENV MINGW_TOOLCHAIN_FILE=/opt/mingw-toolchain.cmake
RUN printf '%s\n' \
'set(CMAKE_SYSTEM_NAME Windows)' \
'set(CMAKE_SYSTEM_PROCESSOR x86_64)' \
'set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)' \
'set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)' \
'set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)' \
'set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32)' \
'# LIBRARY/INCLUDE/PACKAGE deliberately left at CMake'"'"'s own' \
'# cross-compiling default (BOTH) - engine/CMakeLists.txt'"'"'s' \
'# BUNDLED SDL2/SDL2_mixer/FluidSynth find_library() calls point at' \
'# build_ext/ (outside this sysroot entirely), which ONLY would' \
'# refuse to search.' \
'set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)' \
'set(CMAKE_EXE_LINKER_FLAGS_INIT "-static-libgcc -static-libstdc++")' \
> "${MINGW_TOOLCHAIN_FILE}"
# SDL2 and SDL2_mixer for Windows: unlike the desktop build above, these
# are libsdl.org's own official prebuilt MinGW devel packages (headers +
# import libs + DLLs for x86_64-w64-mingw32), not built from source here -
# SDL2's autotools setup targets *nix; upstream ships MinGW builds
# pre-made instead, same as engine/'s own upstream Windows build script
# (build_win64.sh) uses. Same version pins as the Linux build above, so
# both desktop builds ship the same SDL2/SDL2_mixer release.
RUN curl -sSLO "https://www.libsdl.org/release/SDL2-devel-${SDL2_VERSION}-mingw.tar.gz" \
&& tar xf "SDL2-devel-${SDL2_VERSION}-mingw.tar.gz" \
&& mkdir -p /opt/prebuilt/win \
&& mv "SDL2-${SDL2_VERSION}/x86_64-w64-mingw32" /opt/prebuilt/win/sdl2 \
&& rm -rf "SDL2-${SDL2_VERSION}" "SDL2-devel-${SDL2_VERSION}-mingw.tar.gz"
RUN curl -sSLO "https://www.libsdl.org/projects/SDL_mixer/release/SDL2_mixer-devel-${SDL2_MIXER_VERSION}-mingw.tar.gz" \
&& tar xf "SDL2_mixer-devel-${SDL2_MIXER_VERSION}-mingw.tar.gz" --exclude=Xcode \
&& mv "SDL2_mixer-${SDL2_MIXER_VERSION}/x86_64-w64-mingw32" /opt/prebuilt/win/sdl2_mixer \
&& rm -rf "SDL2_mixer-${SDL2_MIXER_VERSION}" "SDL2_mixer-devel-${SDL2_MIXER_VERSION}-mingw.tar.gz"
# GLEW for Windows: compiled directly instead of via GLEW's own bundled
# cross-compile Makefile configs (config/Makefile.linux-mingw64 et al) -
# those pass raw `-soname`/`--out-implib` straight to whatever $(LD) is
# set to, which only works if LD is the real `ld` binary, not gcc-as-
# linker-driver (and that config also hardcodes a 32-bit `i686-w64-
# mingw32` host despite the "64" in its name) - simpler and more robust
# to just compile+link GLEW's one source file ourselves. Produces
# glew32.dll (to ship alongside systemshock.exe) and libglew32.dll.a (the
# MinGW import library engine/CMakeLists.txt's WIN32 branch links
# against).
RUN curl -sSL -o "glew-${GLEW_VERSION}.tgz" \
"https://sourceforge.net/projects/glew/files/glew/${GLEW_VERSION}/glew-${GLEW_VERSION}.tgz/download" \
&& tar xf "glew-${GLEW_VERSION}.tgz" \
&& cd "glew-${GLEW_VERSION}" \
&& mkdir -p /opt/prebuilt/win/glew/include/GL /opt/prebuilt/win/glew/lib \
&& x86_64-w64-mingw32-gcc -DGLEW_NO_GLU -O2 -Iinclude -c src/glew.c -o glew.o \
&& x86_64-w64-mingw32-gcc -shared \
-Wl,--out-implib,/opt/prebuilt/win/glew/lib/libglew32.dll.a \
-o /opt/prebuilt/win/glew/lib/glew32.dll \
glew.o -lopengl32 -lgdi32 -luser32 -lkernel32 \
&& cp include/GL/glew.h include/GL/wglew.h /opt/prebuilt/win/glew/include/GL/ \
&& cd .. && rm -rf "glew-${GLEW_VERSION}" "glew-${GLEW_VERSION}.tgz"
# fluidsynth-lite for Windows: same source/ref/DLL-mode patch as the
# desktop build above, cross-compiled via the MinGW toolchain file. WIN32
# skips fluidsynth-lite's own pthread dependency (see its CMakeLists.txt),
# so no libwinpthread linkage to worry about here.
RUN git clone https://github.com/EtherTyper/fluidsynth-lite.git fluidsynth-lite-win \
&& cd fluidsynth-lite-win \
&& git checkout "${FLUIDSYNTH_LITE_REF}" \
&& sed -i 's/DLL"\ off/DLL"\ on/' CMakeLists.txt \
&& rm -rf .git \
&& cd .. \
&& cmake -S fluidsynth-lite-win -B build-fluidsynth-win \
-DCMAKE_TOOLCHAIN_FILE="${MINGW_TOOLCHAIN_FILE}" \
&& cmake --build build-fluidsynth-win -j"$(nproc)" \
&& mkdir -p /opt/prebuilt/win/fluidsynth-lite/lib /opt/prebuilt/win/fluidsynth-lite/include \
&& cp build-fluidsynth-win/src/*.dll build-fluidsynth-win/src/*.dll.a \
/opt/prebuilt/win/fluidsynth-lite/lib/ \
&& cp -a fluidsynth-lite-win/include/. /opt/prebuilt/win/fluidsynth-lite/include/ \
&& cp build-fluidsynth-win/include/fluidsynth/version.h \
/opt/prebuilt/win/fluidsynth-lite/include/fluidsynth/version.h \
&& rm -rf fluidsynth-lite-win build-fluidsynth-win
# Node.js: needed only so Gitea/GitHub Actions' JS-based actions can run # Node.js: needed only so Gitea/GitHub Actions' JS-based actions can run
# when this image is used as a CI job's container - see NODE_VERSION above. # when this image is used as a CI job's container - see NODE_VERSION above.
RUN curl -sSL -o /tmp/node.tar.xz \ RUN curl -sSL -o /tmp/node.tar.xz \
@@ -283,7 +385,15 @@ RUN git clone --branch "release-${ANDROID_OPENXR_VERSION}" --depth 1 \
COPY build-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY build-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY build-image/build-engine.sh /usr/local/bin/build-engine.sh COPY build-image/build-engine.sh /usr/local/bin/build-engine.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-engine.sh # 755, not +x: the entrypoint drops to a `builder` user matching the
# *host's* UID/GID (see docker-entrypoint.sh), which is never in these
# root-owned files' group, so it needs the image's own explicit
# world-read+execute here - `chmod +x` alone only adds execute bits on
# top of whatever "other" permissions the source file happened to have
# (which depends on the host's umask at checkout, e.g. a restrictive
# 0007 umask yields unreadable-by-other files, which then round-trip
# into the image and cause a Permission denied at container run time).
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-engine.sh
# Marks a shell as already running inside this image (with every engine # Marks a shell as already running inside this image (with every engine
# build dependency prebuilt above) - lets the Makefile's `engine` target # build dependency prebuilt above) - lets the Makefile's `engine` target
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Cross-compiles engine/ (the vendored Shockolate snapshot) for Windows
# (x86_64) via MinGW, against the dependencies prebuilt into this image at
# /opt/prebuilt/win - no network access needed. engine/CMakeLists.txt
# already has working WIN32/MINGW branches (the upstream Shockolate
# project's own build_win64.sh/appveyor.yml build the same way, natively
# on Windows via Git Bash/MinGW; this cross-compiles the same branches
# fully offline and reproducibly from Linux instead), so no source
# patching is needed, unlike the Quest build's android/engine-patches/.
#
# Builds from a scratch copy of engine/ (like android/engine-patches/
# does for the Quest build) rather than engine/ itself: its BUNDLED
# dependency paths (build_ext/built_sdl etc.) and in-source CMake cache
# are relative to/inside the source tree, and would otherwise collide
# with the Linux desktop build's own build_ext/CMakeCache.txt (see
# build-engine.sh) if both are built from the same checkout, as `make
# dist` (dist-linux + dist-win) does.
#
# Must be run with the repo root as the working directory - either via
# ../run-image.sh, or directly when already inside this image (see the
# Makefile's `engine-win` target, which picks whichever of these
# applies, same as `engine`).
#
# Output lands in engine/.build-output-win/: systemshock.exe, the DLLs
# it needs at runtime, and a default MIDI soundfont - everything the
# root Makefile needs to assemble dist-win/.
set -euo pipefail
REPO_ROOT="$(pwd)"
SCRATCH_ENGINE="$REPO_ROOT/build/win-engine"
OUT_DIR="$REPO_ROOT/engine/.build-output-win"
echo "== Preparing a scratch copy of engine/ for the Windows cross-build =="
rm -rf "$SCRATCH_ENGINE"
mkdir -p "$(dirname "$SCRATCH_ENGINE")"
# See build-engine.sh for why this detection is needed (e.g. a VirtualBox
# vboxsf shared folder refusing to create symlinks or preserve hard-link
# relationships) - applied here too since build/ is under the same
# bind-mounted repo checkout.
CP_FLAGS=(-a)
mkdir -p "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir"
if ! ln -s test-target "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir/test" 2>/dev/null; then
echo "(destination filesystem doesn't support symlinks - copying real file content instead)"
CP_FLAGS=(-a --dereference --no-preserve=links)
fi
rm -rf "$(dirname "$SCRATCH_ENGINE")/.symlink-test-dir"
cp "${CP_FLAGS[@]}" "$REPO_ROOT/engine" "$SCRATCH_ENGINE"
cd "$SCRATCH_ENGINE"
# If engine/ has ever been built in-source directly (`make dist`/`make
# engine` - see build-engine.sh), that leftover CMakeCache.txt etc. just
# got copied along verbatim - and it still points at the real engine/
# path, not this scratch copy, which CMake refuses to configure against
# ("directory is different than the directory where CMakeCache.txt was
# created"). Same artifact list as .gitignore's "Engine build artifacts"
# section.
rm -rf build_ext CMakeCache.txt CMakeFiles cmake_install.cmake Makefile \
systemshock src/Libraries/CMakeFiles
echo "== Wiring up prebuilt SDL2/SDL2_mixer/GLEW/fluidsynth-lite (Windows/MinGW) from the image =="
rm -rf build_ext
mkdir -p build_ext
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/sdl2 build_ext/built_sdl
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/sdl2_mixer build_ext/built_sdl_mixer
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/glew build_ext/built_glew
# engine/CMakeLists.txt's BUNDLED FluidSynth mode hardcodes
# build_ext/fluidsynth-lite/src as the library search path (matching the
# desktop Linux in-source build's own output layout) - same lib/ -> src/
# remapping build-image/prepare-android-project.sh does for the Quest
# build's own prebuilt fluidsynth-lite.
mkdir -p build_ext/fluidsynth-lite/src build_ext/fluidsynth-lite/include
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/fluidsynth-lite/lib/. build_ext/fluidsynth-lite/src/
cp "${CP_FLAGS[@]}" /opt/prebuilt/win/fluidsynth-lite/include/. build_ext/fluidsynth-lite/include/
echo "== Configuring (CMake, MinGW cross-compile, BUNDLED SDL2/SDL2_mixer/FluidSynth) =="
cmake -DCMAKE_TOOLCHAIN_FILE="$MINGW_TOOLCHAIN_FILE" \
-DENABLE_SDL2=BUNDLED -DENABLE_SOUND=BUNDLED -DENABLE_FLUIDSYNTH=BUNDLED .
echo "== Compiling =="
make -j"$(nproc)" systemshock
echo "== Assembling engine/.build-output-win =="
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
cp systemshock.exe "$OUT_DIR/"
# Only SDL2.dll/SDL2_mixer.dll themselves - not SDL2_mixer's own bundled
# codec DLLs (libvorbis, libmodplug, libopus, ...), matching what
# engine/'s own upstream Windows build script (build_win64.sh) ships:
# Shockolate only ever calls Mix_LoadWAV_RW/Mix_HookMusic (same as the
# Quest build - see build-image/Dockerfile's Android layer comment),
# never loading the OGG/MOD/MP3 game data those codecs would be for.
cp build_ext/built_sdl/bin/SDL2.dll build_ext/built_sdl_mixer/bin/SDL2_mixer.dll "$OUT_DIR/"
cp build_ext/built_glew/lib/glew32.dll "$OUT_DIR/"
cp build_ext/fluidsynth-lite/src/*.dll "$OUT_DIR/"
# libgcc/libstdc++ are statically linked (see the Dockerfile's
# MINGW_TOOLCHAIN_FILE), but fluidsynth-lite/SDL2 still pull in the
# MinGW pthread emulation dynamically.
cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll "$OUT_DIR/"
cp /opt/prebuilt/soundfont/default.sf2 "$OUT_DIR/soundfont.sf2"
echo "== Done =="
echo "Binary: $OUT_DIR/systemshock.exe"
echo "DLLs: $OUT_DIR/*.dll"
echo "Soundfont: $OUT_DIR/soundfont.sf2"
+23 -4
View File
@@ -22,9 +22,28 @@ cd "$ENGINE_DIR"
echo "== Wiring up prebuilt SDL2/SDL2_mixer/fluidsynth-lite from the image ==" echo "== Wiring up prebuilt SDL2/SDL2_mixer/fluidsynth-lite from the image =="
rm -rf build_ext rm -rf build_ext
mkdir -p build_ext mkdir -p build_ext
cp -a /opt/prebuilt/built_sdl build_ext/
cp -a /opt/prebuilt/built_sdl_mixer build_ext/ # Some filesystems the repo might be checked out on (e.g. a VirtualBox
cp -a /opt/prebuilt/fluidsynth-lite build_ext/ # vboxsf shared folder) refuse to create symlinks at all ("Operation not
# permitted"), which plain `cp -a` needs for SDL2/SDL2_mixer's
# libFoo.so -> libFoo.so.N -> libFoo.so.N.M dev-symlink chain. Detect
# that up front, once, and copy real file content instead of recreating
# links if so, rather than failing partway through. `-a` implies
# --preserve=all (which includes hard-link relationships between files,
# not just symlinks - SDL2's install hard-links the two most-specific
# version files together), so --no-preserve=links is needed alongside
# --dereference to avoid that too.
CP_FLAGS=(-a)
if ! ln -s test-target build_ext/.symlink-test 2>/dev/null; then
echo "(destination filesystem doesn't support symlinks - copying real file content instead)"
CP_FLAGS=(-a --dereference --no-preserve=links)
else
rm -f build_ext/.symlink-test
fi
cp "${CP_FLAGS[@]}" /opt/prebuilt/built_sdl build_ext/
cp "${CP_FLAGS[@]}" /opt/prebuilt/built_sdl_mixer build_ext/
cp "${CP_FLAGS[@]}" /opt/prebuilt/fluidsynth-lite build_ext/
echo "== Configuring (CMake, BUNDLED SDL2/SDL2_mixer/FluidSynth) ==" echo "== Configuring (CMake, BUNDLED SDL2/SDL2_mixer/FluidSynth) =="
rm -f CMakeCache.txt rm -f CMakeCache.txt
@@ -38,7 +57,7 @@ rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/lib" mkdir -p "$OUT_DIR/lib"
cp systemshock "$OUT_DIR/" cp systemshock "$OUT_DIR/"
find build_ext/built_sdl/lib build_ext/built_sdl_mixer/lib build_ext/fluidsynth-lite/src \ find build_ext/built_sdl/lib build_ext/built_sdl_mixer/lib build_ext/fluidsynth-lite/src \
-name '*.so*' -not -name '*.la' -exec cp -a {} "$OUT_DIR/lib/" \; -name '*.so*' -not -name '*.la' -exec cp "${CP_FLAGS[@]}" {} "$OUT_DIR/lib/" \;
cp /opt/prebuilt/soundfont/default.sf2 "$OUT_DIR/soundfont.sf2" cp /opt/prebuilt/soundfont/default.sf2 "$OUT_DIR/soundfont.sf2"
echo "== Done ==" echo "== Done =="
+13
View File
@@ -11,4 +11,17 @@ USER_GID="${HOST_GID:-1000}"
groupadd -g "$USER_GID" builder 2>/dev/null || true groupadd -g "$USER_GID" builder 2>/dev/null || true
useradd -u "$USER_UID" -g "$USER_GID" -m -s /bin/bash builder 2>/dev/null || true useradd -u "$USER_UID" -g "$USER_GID" -m -s /bin/bash builder 2>/dev/null || true
# /workspace (the bind-mounted repo) may actually be owned by a group the
# host user only has via *supplementary* membership rather than their
# primary GID above - e.g. a VirtualBox vboxsf shared folder, which shows
# up as root:vboxsf on the host and would otherwise be inaccessible to
# `builder` here (falls through to "other", which vboxsf's default mode
# leaves with no permissions at all). Join whatever group actually owns
# /workspace too, if it differs.
WORKSPACE_GID="$(stat -c %g /workspace 2>/dev/null || true)"
if [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "$USER_GID" ]; then
groupadd -g "$WORKSPACE_GID" workspace 2>/dev/null || true
usermod -aG "$WORKSPACE_GID" builder 2>/dev/null || true
fi
exec gosu builder "$@" exec gosu builder "$@"
+4
View File
@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
systemshock.exe %*
exit /b %errorlevel%