Compare commits
7 Commits
2b52b818e8
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a2fb95e57b | |||
| 581af95e7b | |||
| 824b4a2506 | |||
| 920842a465 | |||
| 3be310ade6 | |||
| 2d8fb49bf5 | |||
| 1be0bebda2 |
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto
|
||||||
|
*.sh text eol=lf
|
||||||
+19
-10
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
ENGINE_OUT := engine/.build-output
|
DIST_WIN_DIR := dist-win
|
||||||
ASSETS_DIR := res/assets/ss_ee
|
ENGINE_OUT := engine/.build-output
|
||||||
BUILD_DIR := build
|
ENGINE_OUT_WIN := engine/.build-output-win
|
||||||
ARCH := $(shell uname -m)
|
ASSETS_DIR := res/assets/ss_ee
|
||||||
|
BUILD_DIR := build
|
||||||
|
ARCH := $(shell uname -m)
|
||||||
|
|
||||||
.PHONY: all dist build-image engine assets package apk android-studio 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
|
||||||
@@ -124,7 +193,7 @@ android-studio:
|
|||||||
./run-image.sh bash build-image/prepare-android-project.sh --host-paths
|
./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
@@ -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.)
|
||||||
|
|||||||
@@ -92,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)
|
||||||
@@ -104,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/`
|
||||||
@@ -117,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
|
||||||
@@ -133,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
|
||||||
@@ -140,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
|
||||||
@@ -306,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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
+547
-140
@@ -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,
|
||||||
|
&keyboardCursorV, &keyboardCursorHit, &keyboardPlaneHit,
|
||||||
|
&keyboardPlaneDistance)) {
|
||||||
|
if (haveHead && keyboardPlaneHit)
|
||||||
|
xr_input_build_beam(hand, &location, fx, fy, fz,
|
||||||
|
fminf(keyboardPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
bool menuPlaneHit = false;
|
||||||
if (menuVisible) {
|
float menuPlaneDistance = 0.0f;
|
||||||
if (hit != g_prev_hit[hand]) {
|
if (!g_game_touch_active[hand] &&
|
||||||
LOGI("XR: %s aim ray %s menu quad (u=%.2f v=%.2f)", kHandName[hand],
|
xr_input_try_overlay(menuOverlay, false, hand, &location, fx, fy, fz, selectDownEdge,
|
||||||
hit ? "entered" : "left", u, v);
|
selectUpEdge, &g_menu_touch_active[hand], &g_menu_prev_hit[hand],
|
||||||
g_prev_hit[hand] = hit;
|
"menu", &menuCursorU, &menuCursorV, &menuCursorHit, &menuPlaneHit,
|
||||||
}
|
&menuPlaneDistance)) {
|
||||||
if (selectDownEdge && hit) {
|
if (haveHead && menuPlaneHit)
|
||||||
xr_menu_touch(u, v, true);
|
xr_input_build_beam(hand, &location, fx, fy, fz,
|
||||||
g_menu_touch_active[hand] = true;
|
fminf(menuPlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc);
|
||||||
} else if (selectUpEdge && g_menu_touch_active[hand]) {
|
|
||||||
xr_menu_touch(u, v, false);
|
|
||||||
g_menu_touch_active[hand] = false;
|
|
||||||
}
|
|
||||||
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) {
|
||||||
if (!hit || !drawReticle)
|
xr_mouse_click(true);
|
||||||
continue;
|
g_game_touch_active[hand] = true;
|
||||||
|
} else if (selectUpEdge && g_game_touch_active[hand]) {
|
||||||
const float kSize = 0.03f;
|
xr_mouse_click(false);
|
||||||
const float verts[] = {
|
g_game_touch_active[hand] = false;
|
||||||
cx - kSize, cy, 0.0f, cx + kSize, cy, 0.0f, cx, cy - kSize, 0.0f, cx, cy + kSize, 0.0f,
|
}
|
||||||
};
|
// gameHit implies gamePlaneHit (both only ever set together above),
|
||||||
glUniform4fv(g_reticle_color_loc, 1, kHandColor[hand]);
|
// kept as a separate out-param for symmetry with
|
||||||
glEnableVertexAttribArray(RETICLE_POSITION_LOC);
|
// xr_input_try_overlay()'s outPlaneHit/outPlaneDistance pair.
|
||||||
glVertexAttribPointer(RETICLE_POSITION_LOC, 3, GL_FLOAT, GL_FALSE, 0, verts);
|
if (haveHead && gameHit)
|
||||||
glDrawArrays(GL_LINES, 0, 4);
|
xr_input_build_beam(hand, &location, fx, fy, fz,
|
||||||
glDisableVertexAttribArray(RETICLE_POSITION_LOC);
|
fminf(gamePlaneDistance, MAX_BEAM_LENGTH_METERS), &headLoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (menuVisible)
|
||||||
|
xr_overlay_update_cursor(menuOverlay, menuCursorU, menuCursorV, menuCursorHit);
|
||||||
|
if (keyboardVisible)
|
||||||
|
xr_overlay_update_cursor(keyboardOverlay, keyboardCursorU, keyboardCursorV,
|
||||||
|
keyboardCursorHit);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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(¢erX, ¢erY, &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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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 =="
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
systemshock.exe %*
|
||||||
|
exit /b %errorlevel%
|
||||||
Reference in New Issue
Block a user