Compare commits

..
54 Commits
Author SHA1 Message Date
bearstonem ead71109fc docs: present OpenMoHAA VR for Quest and Steam Frame 2026-10-14 14:59:59 -07:00
bearstonem aaf287d1fb build(vr): prepare Quest 0.2.0 prerelease
Track the Android Gradle build scripts excluded by the broad build ignore rule. Bump the Android version code and identify the Quest release in the version name.

Ship normal menu startup with cheats, test-map loading, weapon grants, and diagnostics disabled. Preserve and document the dynamic-lighting workaround in the external config.
2026-10-14 14:51:04 -07:00
bearstonem d9968210aa fix(vr): correct visual glitches and reduce rendering overhead
Restore desktop HUD behavior and objective state, fix stereo text lifetimes, HUD placement and colors, sprite culling, and font shader passes. Handle swapchain timeouts and incomplete stereo frames safely, remove forced GPU waits, and correct rendering timing attribution.

Track client-game source additions in CMake. Validated desktop and Android GL1/GL2 builds plus targeted regression and sanitizer checks. Headset appearance and performance remain unverified.
2026-10-14 14:48:22 -07:00
bearstonem bd72599ab7 feat(vr): the HUD comes up when asked, and the compass is whole
The group is hidden now. The off hand's X brings it up for vr_hudTime seconds
and then it fades over 600ms. Almost everything it carries - how much ammo is
left, which way the objective is - is only wanted at the moment it is asked
for, and one that is always there is always in the way of a game about looking
at things.

The press is a timestamp rather than a toggle. The VR layer writes
Sys_Milliseconds into vr_hudShow on the button's down edge and cgame reads it
against cgi.Milliseconds, which is the same function, so there is no state to
latch and no edge to clear on the far side - and a second press during the fade
simply restarts it. Only the down edge counts: holding the button should not
keep restarting the timer, and should not hold the group open either, because a
button held down is a button the player has forgotten about. vr_hud 2 pins it
up, which is what to use while arguing with the layout.

X used to jump, and jump was also on the right hand's A, so it was a button
spent twice. The HUD is worth one of them and jump keeps A. Left Y is the
better candidate and is not used here: it still sends +scores for an objectives
overlay that no longer appears on its own, since the objectives moved into this
group and vr_objectivesAlways forces them on regardless. Moving the HUD there
would cost nothing, and would give X back to jump.

The compass has the two layers it was missing. The damage ring is
stats[STAT_DAMAGEDIR] drawn through compasspain, fading over 1500ms from
whenever the value last changed - it is a headingspinner, which unlike the
objective is the variant that does subtract the view angles, so the direction
arrives in world terms and has to be brought round to the head, and the +180 in
that is because the stat points at the attacker while the ring marks where the
damage came in. The objective is compassobjarrow turned to its angle rather
than the pip that stood in for it while nothing here could draw anything
rotated.

vr_hudGroupY is 0.92, which is 0.63 of the eye. 0.68 is as low as this can
reach at all - vr_hudScale compresses everything towards the middle of the
display, so past that the cvar has nothing left to give and the size has to come
down instead.

And a guard where the font bug actually lives. glGenTextures never legitimately
hands back 0 - it is the reserved name - so a zero means the call did not write,
and nothing downstream notices: Upload32 uploads into texture zero, GL_Bind
binds it, and the surface samples opaque black, which is what every string in
the game looked like. R_CreateImage now asks a second time and says so if it
still comes back empty.

That is a guard and not the cure, because the cause is still unknown. What is
known is that three of the four fonts recover and verdana-14 does not, that it
is the first one created, and that its image is alive and correctly named with
no texture attached - so it was never freed, and R_FreeImage and R_DeleteTextures
both memset, which is how that was ruled out. The trace prints every font atlas
as it is created, with its slot and its texnum, because from the outside an
image that was never created and one created without a texture are
indistinguishable by the time anything draws with it, and telling those apart is
the next step. Until it is found, CG_HudDrawElements still draws its text as
boxes.
2026-08-02 23:35:31 -07:00
bearstonem e6a7c76f84 feat(vr): one HUD in the middle of the view, and the two bugs that hid it
The retail HUD is five menus from ui/hud_*.urc, each aligned to a corner of a
4:3 screen. On a monitor those corners are a glance away; through a lens they
are the far edge, present and unreadable. cg_vrhud.cpp draws the same
information again as one group below the centre of the view.

Nothing is read back out of the widgets, because there is nothing in them to
read: every one is a formatter over cl.snap.ps.stats[], thirty-two integers,
and the .urc files are pure layout - hud_health.urc is "playerstat 0,
maxplayerstat 1, draw it as a vertical bar". cgame has the same array as
cg.snap->ps.stats[]. The .urc files were still worth reading, as the spec for
which stat index and which shader each element uses, and the decode of the two
compass angles is taken from the widgets that already do it rather than guessed:
the heading is the eye yaw less stats[STAT_COMPASSNORTH]/182, a sixteen bit
angle, and the objective is stat/10 - 180 and is already relative to the view,
because hud_compass.urc draws it as a plain spinner and not the headingspinner
variant that subtracts the view angles itself.

It is drawn from cgame rather than as another widget for a reason beyond taste.
cgame's 2D goes through the one full screen window UIView3D::set2D sets up, and
that window is converged as a whole. A widget would arrive with its own window,
which is the first bug below.

Convergence: the correction belongs to the display, not to the window.

R_VRAdjust2DOrtho bent whatever ortho window it was handed, and in this engine
that window is usually one widget - UIWidget::set2D cuts a viewport and an ortho
window to each widget's own frame. The correction was therefore scaled to the
widget, and a 150 pixel compass in a 1680 pixel eye got about a twelfth of the
shift it needed while the objectives, drawn through the full screen window, got
all of it. Every element ended up with a disparity in proportion to its own
width, so each sat at a different apparent depth, and no vergence resolves that
set - which is what "the HUD will not converge" was.

It is R_VRAdjust2DRect now, applied to the viewport and scissor rectangle in
eye buffer pixels, which every 2D window shares. The scissor has to move with
it or a widget the shift has displaced is clipped away at where it used to be.
vr_hudOffset goes back to 1 in autoexec.cfg: 0 was set on a measurement of the
old bug, and it is the one value that cannot fuse at all, because the centre of
each eye's buffer is about seven degrees off its own axis in opposite
directions, so it asks the eyes to diverge by fourteen degrees.

Text: the font atlases had no texture.

Every string drew as a flat black rectangle - 0,0,0 with no glyph structure,
measured off an eye dump rather than a compositor screenshot. The atlas is a 32
bit TGA with a real alpha channel and the shader is correct, so neither of the
obvious causes held. The shader's own stage reported texnum 0, and GL_Bind binds
image->texnum straight, so it was binding texture zero, which samples opaque
black.

The image is alive and correctly named, so it was never freed - R_FreeImage and
R_DeleteTextures both memset, and the name survived. texnum was zero from
creation. R_LoadFontShader now notices and reloads, but it has to free the image
first: R_FindImageFileOld looks in the hash before it looks on disk, so asking
by name returns the very image that has no texture. Three of the four fonts come
back with a texture; verdana-14, the first one created, still comes back as zero
on a fresh load from disk, which means qglGenTextures is not writing to it and
the real bug is upstream in image creation. That is not fixed here, and
CG_HudDrawElements still draws its text as boxes because of it.

Also in here: R_DrawTrianglePic added to the cgame imports, which the UI has
always had as re.DrawTrianglePic and is the only way to draw anything rotated,
so the compass card can turn under a fixed needle the way hud_compass.urc does
it. The retail corner HUD is hidden from the "Hide the HUD when necessary" block
in UI_Update rather than from the explicit Display calls above it, because that
branch returns early and is not the path taken - when it is not, UpdateViews
walks the whole tree and draws every shown menu regardless, which is how
hud_health and hud_ammo stayed on screen through the first attempt.

Bars are R_DrawBox rather than a stretched gfx/2d/blank. That shader is declared
alphaGen oneMinusVertex, which inverts the alpha: the bars asked for at 1 came
out at 0 and the backdrop asked for at 0.35 came out at 0.65.

vr_hudGroupY is 0.73 and does not mean what it looks like. It places the cluster
in the flat frame, which is then scaled to vr_hudScale about the middle of the
display and lifted by the eye's vertical frustum asymmetry, 170 pixels of 1760
here - so 0.73 is a little below the middle of what is actually seen and 0.62
was a little above it. The reachable range is about 0.13 to 0.68 of the eye.

Not done: the damage direction ring, the objective as compassobjarrow rather
than a pip, and the compass worth having, which is one the player takes out of a
pocket and holds.
2026-08-02 23:16:00 -07:00
bearstonemandClaude Opus 5 bec1f4a8d5 fix(render): stop dynamic lights darkening the world, and record why
Firing in the Bocage multiplies roughly a sixth of the screen down by about 4x
for as long as the trigger is held. It is the ground plane, with hard polygon
edges, and it is not black - the texture is still there underneath. r_worldDlights
0 in autoexec.cfg stops the world responding to dynamic lights at all, which
loses flashes lighting the scenery and is the smaller loss.

The cause is not found. Seven theories were tried and all seven were wrong, so
what this mostly commits is the evidence, in docs/markdown/04-coding/05-dlights.md
next to the gl4es notes, so that picking it up again does not start from nothing:
the cadence table and the frame difference that measured the 4x, a table of every
dead theory with what killed it, the worked texcoord arithmetic showing the faces
land correctly, and the one hard contradiction to resume on - every component of
the dynamic lightmap path measures correct end to end, and switching the path off
makes the symptom stop.

Two things in there are recorded specifically because they look damning and are
not. "texels 9275" against "rows 28" cannot fit, but R_RealDlightFace counts
c_dlightTexels before it knows whether any light landed and then bails at
!added without committing to dli.allocated, so failed surfaces are counted and
take no space. And R_UploadDlights' guard on tr.pc.c_dlightSurfaces reads like it
must always be zero from the back end, because R_IssueRenderCommands clears the
counters before running the command list - it is not what happens, measured at 35
surfaces. That guard is gone anyway: the height below it is the same test done
against the allocator's own state rather than against a debug counter.

The workaround is its own cvar rather than a level of vr_traceDlight, which is
what it was while it was being tested. A diagnostic switch that also changes
behaviour means turning tracing off silently brings the bug back, and this port
has already lost a day to a cvar that did not do what its name said.

The diagnostics stay, off. 1 reports what each upload contained and where every
dlit face landed in the atlas; 2 stains the whole atlas white and paints the lit
texels magenta, which is read off the walls rather than out of the log; 3 skips
ProjectDlightTexture, which was measured never to run here and is kept because
proving that cost a run. The first stain was dark green and was useless in a
level made of hedgerows - white can only brighten, so it cannot be mistaken for
foliage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 17:12:39 -07:00
bearstonemandClaude Opus 5 6f443949ae fix(vr): the death screen was drawn on the player's face
The stats screen the game raises when the player dies, and again at the end of
a mission, went into both eye buffers as screen space 2D - at the near plane, a
hand's width away, with no disparity to fuse and each eye warping it through its
own asymmetric frustum. That is the same fault 013b2f5b fixed for the mission
briefing, and the fix there did not cover this one for two separate reasons.

cg_view.c raises it with UI_ShowMenu, which sends the widget a showmenu event
directly instead of going through MenuManager. The menu never reaches either
menu stack, so CurrentMenu() does not return it and UI_FullscreenMenuUp() cannot
see it however fullscreen it is.

And it is not fullscreen: statsscreen_failed.urc has its "fullscreen 1" line
commented out. It is a 640x480 overlay the flat game draws over the live view,
so even as the current menu it would still answer no.

So neither test could have caught it, and asking a third question about menus
would have been guessing at the next one. It now asks about intermission, which
is the same condition CG_DrawActiveFrame itself uses to decide to put the screen
up - if the game thinks it is showing a stats screen, the panel is where it
goes. That also covers mission success, the MOHTA mission_* screens and the
multiplayer scoreboard, which are the same kind of content with the same
problem.

Confirmed on the device: the screen is now a quad in the room. Its text renders
as black boxes with a sliver of the font atlas showing through, which is a
separate defect and untouched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 17:12:19 -07:00
bearstonemandClaude Opus 5 7bb71e7550 build(vr): the knobs this session added, and the harness back on
The throw cvars are set here as well as defaulted in code, and that is the point
rather than duplication. They are CVAR_ARCHIVE, so a device that has run before
keeps whatever omconfig.cfg holds and a changed default in the source does
nothing at all. That cost a build: the floor stayed at its old 0.45 while the
curve moved under it, and every throw came out clamped to the same 45% however
hard it was thrown. autoexec runs after omconfig and wins.

Measured values, not guesses - vr_throwFull 340 is a hard overhand, against
gentle lobs at 145 to 192.

Also on: vr_traceUse, vr_traceHolster and vr_traceThrow, and the test harness -
cheats, give all, and m4l1 - because weapon work is still in progress. None of
that should ship; see 6e86e55c for turning it back off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:49:25 -07:00
bearstonemandClaude Opus 5 711cad20e6 feat(vr): reach into the world - use, holsters, ladders and thrown grenades
Four interactions that all needed the same thing: the hands' positions and
velocities on the other side of the game module boundary. They share the
plumbing, so they share a commit.

use moves to B on the weapon hand, where the reference puts activate. The
objectives list keeps the left hand's button; it had been bound to both, which
was a button spent twice on one rarely opened screen. The trace also starts from
the head's real position now rather than the body's notional eye - the two come
apart exactly when leaning through a doorway to reach the thing on the far side.

Weapons live on the body, the way Half-Life: Alyx does it: pistol at the right
hip, grenades at the left, rifle and smg over the shoulders. Reaching back to
where a weapon came from puts it away, reaching anywhere else swaps - holstering
on any zone would have cost two gestures for every swap. Zones are measured from
the head and rotated by the head's heading, so they stay on the body rather than
wandering as the player turns.

Ladders are climbed by pulling on them. MOHAA moves a climbing player by the
root motion of the climb animation, with pmove skipped entirely, so this runs
after the animation rather than instead of it: the animation still plays and the
distance travelled is the distance the hand pulled. Both grips, averaged when
both are held, and the grips stop meaning reload and rifle-steady while on a
ladder.

Grenades leave along the arm. Which instant of a swing to read is the whole
problem and took several passes: averaging across the release blends the wind up
with the follow through; the peak of the whole swing is usually the *backswing*,
which threw grenades over the player's shoulder. What works is the natural
gesture's own shape - take the direction the hand had as it let go, then find
the fastest sample that agrees with it, which excludes the wind up by
construction. Strength is a fraction of the game's own throw so all of MOHAA's
grenade tuning survives.

The sideways damping is the one deliberate lie. An underhand swing crosses the
body, so the hand really is moving sideways at release - measured, lobs drifted
left by up to 0.73, more sideways than forward. Damped in proportion to how
gentle the throw is, so hard throws keep their true direction.

A weapon wheel is included and is not finished: its input works and a tap of the
same stick holsters, but the ring never appeared on screen and the drawing was
not chased down. It is harmless - a hold selects nothing and does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:49:25 -07:00
bearstonemandClaude Opus 5 15471f7578 feat(vr): a hand is always drawn, and the weapon hand is only a hand
Two changes to what the first person model is for.

Being unarmed is not a reason to have no hands. Flat, that model exists only to
hold a weapon, so putting the gun away hides the arms with it and nobody
notices; in a headset the player is holding two controllers whether or not there
is anything in them, and hands that vanish read as tracking having dropped out.
No mesh work was needed - triggerhand and lefthand are already separate
surfaces, so the unarmed case shows those two and hides garandhand, a left hand
posed around a rifle that is not there. Zoom, death and cg_drawviewmodel still
hide, being about something else entirely.

The weapon hand's copy now draws triggerhand alone. viewsleeves is a single mesh
carrying *both* forearms - weighted 27% to Bip01 R Forearm and 27% to
Bip01 L Forearm, with both elbows, shoulders and clavicles - so drawing it on a
model anchored to the right controller hung a disembodied left sleeve in the air
beside it. What is left is a whole hand rather than a stump: triggerhand is
weighted to Bip01 R Hand, every R finger bone, and helper Rwrist. The wrist is
in, the forearm is not, and there is no way to get part of one without
re-authoring - the forearm exists only inside the two-armed sleeve.

Those weights were read out of the retail USarmyplyr.skd rather than guessed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:49:00 -07:00
bearstonemandClaude Opus 5 74dc964962 fix(vr): the use trace was aimed at the player's feet
Use worked about half the time, and the half it worked was luck.

cg.refdefViewAngles is not only the view. CG_OffsetFirstPersonView overwrites it
with the player model's animated *head bone* orientation on the paths that place
the view from a world position, and CG_EyeAngles samples whatever is left there
when the packet is written. Those angles travel up in the usercmd as usereyes_t
and become Player::m_vViewAng, which is what getUseableEntities aims with.

Measured on device, that left a pitch pinned between 73 and 86 degrees. Every
press traced about 77 units into the floor and stopped there - the trace
fractions were 0.87 to 0.93 - and the box at the end of it caught the player's
own entity plus whatever trigger happened to be underfoot. Standing on a
TriggerUse was what "working" meant.

In VR the eye angles now come from the player state's view angles, which are the
headset's and only the headset's: CL_VRMove writes the head's yaw and pitch into
them every frame and nothing animated gets a say. No recoil kick or lean in them
either, which is right for reaching at a door handle.

Flat play keeps the old source. There the two agree except for kick and lean,
and changing where a desktop player's use is aimed is not what this is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:49:00 -07:00
bearstonemandClaude Opus 5 b51ed2e430 docs: the shipped config no longer starts in the test map
Two spots in the status went stale with the change above. The device
configuration section said the harness ships on and to clear it to play
properly, which was true when written and is now the reverse. The hands section
said both hands need a roll of 180 in the config, which is now the compiled
default.

Both replaced with the rule they are instances of: nothing the game needs to be
correct lives only in autoexec.cfg, because that file is not in the APK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:36:02 -07:00
bearstonemandClaude Opus 5 aa2fbeaf94 fix(vr): the hand rotation the models need is the default, not an override
vr_weaponAdjust and vr_offHandAdjust both defaulted to a zero roll, and the 180
that makes the hands come out the right way up existed only in
misc/android/autoexec.cfg. That file is deliberately not packaged into the APK
- it lives next to the game data, so that the config survives reinstalling the
build - which means anyone installing the APK on its own got upside-down hands
and no way to guess why.

Both defaults are now 180, which is the value measured on device rather than a
guess. The config still sets them, so it stays the place to tune them from;
it is no longer the only place the right answer exists.

The comment explains why the number is not arbitrary: anchoring forces the
bone to take the controller's orientation, and a wrist bone's local axes do not
run the way a controller is held, so the mesh inherits that mismatch as a
constant half turn about the aim axis. The two hands are mirrored and were
still measured to need the same 180, which is why they remain two cvars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:35:03 -07:00
bearstonemandClaude Opus 5 e45388bd01 chore(vr): the config that ships starts the game, not the test map
The harness was switched back on to tune the hands and left on: cheats, every
weapon handed over a second after spawn, and m4l1 loaded straight past the
menu. That is how you tune weapon alignment without replaying a level on every
build, and it is not how anyone should be handed the game.

cheats, vr_testStart and vr_startMap are empty again, with the working values
kept in the comment beside the explanation of why each needs its own timing.
The commented example said vr_startMap "" - which is the off value, not the
harness value, so the comment did not actually record how to turn it back on.
It says m4l1 now.

The reasoning for why m4l1 is the right test map stays where it is. It was
worked out from the game's own scripts rather than from reputation, and it will
be wanted again the next time the hands need looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:34:53 -07:00
bearstonemandClaude Opus 5 dcd85654a4 docs: record the frame cost of full resolution, and what it rules out
Section 4 said rendering at 36% of the pixels was the largest cheap win left. It
has been taken, and it was not cheap - it was a trade.

Measured in the Bocage at 1680x1760 per eye: 90 fps standing at spawn with an
8-9 ms eye pass, 77-85 fps moving with 11-12 ms against an 11.1 ms display
period, so most frames while moving are reprojected. Kept on how it plays rather
than on the number, which is a legitimate call and now a recorded one instead of
something for the next reader to rediscover and wonder about.

Two consequences replace the old next steps. MSAA is out - not because it is
expensive, since it resolves in tile memory, but because there is nothing spare
on the CPU side and the GPU was never the constraint. And the frame split cannot
be trusted: it reports world 1 ms against hud 5 ms, which is Set2DWindow's flush
charging the world's deferred draws to the first 2D call. gpuwait is still 3 ms,
so the GPU idles and the missing time is CPU issuing draw calls.

Fixing that attribution is now step 1, because half the frame is presently
charged to the wrong thing and every later decision about the budget rests on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:23:57 -07:00
bearstonemandClaude Opus 5 214933c5fe perf(vr): render the eyes at the resolution the runtime asks for
vr_resolutionScale has been at 0.6 since the days when a frame cost 50-60 ms per
eye, which meant the headset was being given 1008x1056 against a native
1680x1760 - 36% of the pixels it has.

The measurement that justified it has since answered the opposite question.
Rendering at 36% of the pixels bought only 23% of the frame time back, which is
what proved the cost was never fill rate: it was CPU in rend2's back end binding
a GLSL program per surface per eye. Swapping to renderergl1 on gl4es took the eye
from 50-60 ms to 4-6, and nothing has claimed that budget since.

So it goes back to 1. The cvar stays rather than being deleted - it is still the
first question worth asking of any frame time regression, and it is the cheapest
thing to trade if a later feature wants budget it has not got.

Measured on the device afterwards, in the Bocage:

    standing at spawn   90.0 fps   eyes 8-9 ms
    moving in the level 77-85 fps  eyes 11-12 ms, most frames over the 11 ms period

So this does not hold 90 Hz while moving and the compositor is reprojecting a
good share of frames. Kept anyway, on the judgement of how it actually plays -
the picture is much sharper and the reprojection is not felt. The number is
recorded here so the trade is a known one rather than a surprise to whoever reads
the frame times next.

Two things follow. There is now no headroom for MSAA. And the frame split cannot
be taken at face value: it reports world 1 ms against hud 5 ms, which is the
Set2DWindow flush attributing the world's deferred draw calls to the first 2D
call that forces them out - the trap in QUEST_PORTING_GUIDE.md section 7.1. The
one figure that is trustworthy is gpuwait, still 3 ms, so the GPU remains idle
and whatever the 11 ms is, it is CPU side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:23:14 -07:00
bearstonemandClaude Opus 5 fb2855f664 docs: bring the status up to date with the port as it stands
It had drifted a long way. It still described renderergl2 as the renderer, said
head aim was the next real piece of work, listed the weapon handling as not
begun, and called the redundant renderer restart harmless when it was the entire
black menu bug. All four are false, and it is the first file a new session reads.

Rewritten against what is actually true and verified, with the phase table, the
build commands and the outstanding list all matched to the tree.

What is new rather than corrected:

  - the frame composition rule, which is the thing most likely to be tripped over
    next: cg.refdef.vieworg is not the camera, both routes to fixing that, and
    why half of each amplifies instead;
  - the hands - separate surfaces, tag anchoring, and the measurement that
    corrected the guess about tag_weapon_left;
  - the colour space, and that the sRGB format alone looks like the whole fix;
  - the world being 52.5 units to the metre and how the game's own art says so;
  - starting the logcat capture before handing over the headset.

Section 4 now says plainly what has *not* been confirmed on the device - the two
handed hold, the reload tap, off hand steering - rather than leaving them to read
as working. And it names the largest cheap win left, which is that
vr_resolutionScale is still at the 0.6 that was set when a frame cost ten times
what it now costs, so the game is rendering 36% of the pixels the headset has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:02:14 -07:00
bearstonemandClaude Opus 5 649f8f682d docs: fold this session's findings into the porting guide
Everything here was paid for on the device over one session, and none of it was
in the guide because none of it had happened yet.

New material:

  4.8.1  The engine's camera variable is probably not the camera. Once the head
         pose is composed in the renderer, refdef.vieworg is short of the real
         camera by the head's play space offset, and the game module never sees
         it. Gives the three symptoms that identify it - drifts on head turns,
         lags snap turns, moves at double rate - and both correct routes, with
         the warning that taking half of each is what produces the third.

  4.8    World scale is the engine's own number, not a feel value. Where to find
         it (an asset pipeline comment, the player bounding box) and why getting
         it wrong reads as oversized hands rather than as a scale error.

  6.4.1  Anchor on the hand, not the model origin: the tag composition inverted,
         which anchor suits which hand, and the instruction to measure every
         candidate rather than trust that it resolved. Records that the guess
         this replaced was wrong.

  6.4    The adjustment string's first field is the model's scale.

  9.1    A map-loading harness has to wait for idle, and the menu is
         CA_CINEMATIC rather than CA_DISCONNECTED.

  9.4    Verifying the change landed, extended to the case where a refactor
         changes what an argument *means* and the compiler cannot help.

  9.4.1  Make silent paths speak - print on every exit, naming the state that
         decided it.

  9.4.2  On-device logs rotate, so start the capture before handing the headset
         over.

The trap index is reordered to put the frame, the meaning-changing refactor, the
world scale and the anchor near the top, which is where their cost puts them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:56:46 -07:00
bearstonemandClaude Opus 5 7ce4e07352 fix(vr): scale the world by the game's own number, and the model by its own field
Reported as the hand and weapon models rendering too large. Two causes, one of
them the same knob failing to do what its name says.

**vr_worldscale was 32.** That is roughly the id Tech number for a game built in
inches and is simply the wrong engine's. MOHAA's art is authored at 16 units per
foot, which its own tik files state where they convert from centimetres:

    models/weapons/bar.tik:
    scale 0.52   // 16/30.5 since world is in 16 units per foot

16 / 0.3048 = 52.5 units to the metre, and the player dimensions agree - MAXS_Z
94 and DEFAULT_VIEWHEIGHT 82 come to 1.79 m standing and 1.56 m to the eye, where
32 would have made the player over two and a half metres tall.

Being out by a factor of 1.64 does not present as a scale error. Everything
measured in metres and handed to the game was too small, so a hand held half a
metre out was placed at 16 units rather than 26 - while the hand *model* is
authored at the game's true scale. A correctly sized model drawn far too close
reads as oversized, and there is nothing in the world to compare it against. Your
own hands are the only reference object, which is why this surfaced now and not
when the world first rendered.

Set in autoexec.cfg as well as changed in code, because vr_worldscale is
CVAR_ARCHIVE and the value already stored on the device beats any new default.
That trap is documented two sections above the one this was found in.

**The adjustment string's scale field did nothing to the model.** It is
scale,right,up,forward,pitch,yaw,roll, and the reference uses the first field as
the entity scale - VrInputWeaponAlign.c reads it into test_scale and cg_weapons.c
returns it as the model's scale. Here it only ever scaled the offset, so it was a
knob that appeared inert on a model that was the wrong size. Now applied to
model->scale, and applied before anything asks for a tag: TIKI_Orientation
multiplies the tag origin by the entity scale, so anchoring first and scaling
afterwards would slide the hand off the controller by whatever the scale changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:56:46 -07:00
bearstonemandClaude Opus 5 43e29a300a docs: how to port these engines to Android and Quest, from three ports
Written after three of them rather than before, and aimed at the next one. The
Quake lineage is where the advice is most exact - id Tech 2/3 and everything
descended from it - but sections 3 to 10 transfer to any C or C++ engine of that
era.

Draws on this port, the Homeworld: Unbound port (1999 engine, GL 1.x on gl4es,
meson) and the openblack VR port (modern C++, bgfx, no gl4es), plus RTCWQuest as
the finished reference all three lean on. Where the three disagree the guide says
so, and where something is believed rather than measured it says that too.

The parts worth having are the ones that cost days: the renderer choice and the
numbers behind it, the gl4es boundary in full, the adb data directory ownership
rules, the sRGB swapchain finding, the off centre frustum sign and why either eye
alone still looks correct, and section 9 on debugging a device with no console -
prove the renderer can draw before believing a negative, show a probe reaches
what it measures, verify the change landed, and never change code and
configuration in the same step.

Section 4.4 carries a correction to the Homeworld notes it came from, which
claimed an sRGB swapchain without write control would over darken. It does not;
it washes out identically to a linear one, and believing otherwise is what let
this port sit in exactly that state for months while reading as already fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:46:31 -07:00
bearstonemandClaude Opus 5 f52d6e5163 build(vr): test on the bocage, and knobs for the hand alignment
vr_startMap was still pointing at m1l1, which opens on a long scripted sequence -
a poor place to look at your hands. m4l1 was chosen off the game's own data
rather than by reputation:

  - its script calls itself THE BOCAGE and opens on the Normandy hedgerows with
    Manon, spawned with her AI disabled and set nodamage, so nothing is shooting
    while you look at your hands;
  - 2 spawn references in 538 lines of script, against m1l2a's 59 in 3660.
    Almost nothing is scripted to happen at the start;
  - global/loadout.scr hands the player a colt45 and an m1_garand and selects the
    rifle, so there is a weapon in hand at spawn.

That last point is why it suits the hands specifically: the Garand is the weapon
that swaps the left hand surface to garandhand, so the level exercises both hand
meshes rather than only the common one.

vr_offHandAdjust is new, in the same seven fields as vr_weaponAdjust. Both carry
a roll of 180 and no offsets. The offsets are zero because each hand is now
anchored on its own bone, so there is no built in offset left to cancel - the
-10 right and -6 up the weapon used to need were correcting an anchor that no
longer exists, and keeping them would push the gun the wrong way. The roll is
not a fudge: anchoring forces the bone to take the controller's orientation, and
a wrist bone's local axes do not run the way a controller is held, so the mesh
inherits a constant twist. Both files say which field to reach for if the model
comes out on its side rather than upside down, and that the two hands are
mirrored so their corrections need not match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:46:11 -07:00
bearstonemandClaude Opus 5 4f5b54c74c feat(vr): a hand on each controller, placed in the renderer's frame
The first person model already has the hands as separate surfaces - triggerhand,
lefthand, garandhand and viewsleeves on USarmyplyr.skd - so putting one on each
controller needs no mesh work at all. The weapon hand's copy drops the left hand
surface and a second copy is drawn on the off hand carrying only that, using the
same Surface_NameToNum and MDL_SURFACE_NODRAW the game already uses to swap
lefthand for garandhand on rifles.

Two things had to be right first, and both were wrong in ways that looked like
tuning problems rather than faults.

**The frame.** cg.refdef.vieworg is not the camera. R_VRComposeView adds the
head's play space offset to it, rotated into the body's frame, after cgame is
finished (tr_vr.c). So anything anchored on vieworg alone is left behind by
exactly the head's offset within the play space - and because the head swings
about the neck, every look around moved it, and a snap turn left it behind
entirely since a stick turn moves the body frame that a head relative offset
knows nothing about.

Both hands are now reported measured from the tracking origin, the frame
VR_PoseToView puts the eyes in, and composed the way the renderer composes the
camera:

    bodyAngles = (0, viewYaw - baseYaw, 0)
    handWorld  = vieworg + bodyAxis * trackingOffset

RTCWQuest reaches the same place by a different road - convertFromVR rotates a
head relative offset by viewangles[YAW] - hmdorientation[YAW] and the -64 plus
hmdposition[1] * worldScale rebase then replaces its height, which comes to the
same thing. Either route works. Taking half of each does not, and did not: a
head relative offset composed as though it were play space leaves the hand at
vieworg + R*(hand - head) while the camera is at vieworg + R*head, so the head's
movement is counted twice and every hand movement is amplified. That is what
"the right hand is accelerated" was, and it happened because VR_GetWeaponPose
was left reporting the old frame while its caller had moved to the new one - the
signature is identical either way, so nothing complained. The header now says so
at length.

**The anchor.** A hand drawn at the model origin sits wherever the animation put
it, which for a rifle is a long way out in front. The model is now moved so the
hand's own bone lands on the controller instead, inverting the composition the
engine itself uses in Entity::GetTagPositionAndOrientation. The off hand anchors
on Bip01 L Hand and the weapon hand on tag_weapon_right, because what the player
is holding there is the gun and the controller is its grip.

Every candidate anchor is measured and logged rather than trusted, which earned
itself immediately: tag_weapon_left was assumed to be an unused slot parked at
the origin and is nothing of the sort (23.5 units out), while the weapon hand's
lookup was returning -1 in silence and anchoring nothing. Both now report what
they found and how far out it is, and the early return says which precondition
failed.

Tracking is confirmed 1:1 on both controllers on the device. What is left is the
wrist bones' own rotation convention, which is a constant and lives in
vr_weaponAdjust and vr_offHandAdjust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:45:57 -07:00
bearstonemandClaude Opus 5 48b5876e3f fix(vr): the menu is CA_CINEMATIC, so the start map never loaded
vr_startMap waits for the client to sit idle before loading, because a map
started from autoexec.cfg runs during Com_Init and the game's own startup tears
it straight back down again. It counted only CA_DISCONNECTED as idle.

MOHAA's main menu is drawn over a static pic, and the enum says what that means:

    CA_CINEMATIC   // playing a cinematic or a static pic, not connected to a server

So the client is CA_CINEMATIC at the menu and never CA_DISCONNECTED. idleSince
was cleared every frame the menu was up, the four second wait could never
accumulate, and the map simply never loaded. Eight seconds sitting at the menu
was plenty; the timer just kept resetting. Confirmed by the state it now prints:
9, which is CA_CINEMATIC.

The reason this took a round trip to find is worth more than the fix. The
vr_testStart branch three lines below prints either way, and says why:

    // Printed either way. A hook that silently does nothing is indistinguishable
    // from one that never ran, and telling those apart has cost enough already.

vr_startMap printed only on success, so its silence could not be told from "ran
with an empty cvar". It now prints unconditionally and names the state that let
it through, so the next time this misbehaves the log says which.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:45:31 -07:00
bearstonemandClaude Opus 5 bd480975a3 fix(vr): stop the driver encoding the engine's colour a second time
Reported as flat lighting - the whole scene lit from everywhere, shadowed faces
pulled up towards lit ones. That reads as a lighting fault and is not one: the
shading is untouched, and a defect that moves the entire scene by one curve is
the pipeline rather than any model's normals.

The swapchains have always been GL_SRGB8_ALPHA8, which is the right declaration.
This engine's output is display referred - the same values a monitor would have
been handed in 2002 - so telling the compositor the images are sRGB is what makes
it pass them through instead of encoding them again.

What the format does not do on its own is stop GL encoding on the way *in*.
ES 3.0 converts linear to sRGB on every write into an sRGB attachment, blits
included, and core ES has no state to turn that off - GL_EXT_sRGB_write_control
is what adds the switch, and it defaults to on. So the driver was encoding
already encoded colour and the compositor was faithfully passing it through:

    stored = encode(V), displayed = encode(V)

which is the same lifted mid tones as declaring the images linear, reached from
the other side. The format line looks like the fix and is half of it, which is
why this survived so long - a port that has done only that half reads as one
that cannot possibly have the washed out bug.

Measured on the Homeworld port, where the same defect arrived by the linear
route: a mid tone the data puts at 0.19 came out at 0.45, against 0.473 for a
straight sRGB encode of it, and the ratio between the brightest and darkest
tenth of a hull collapsed from about 5:1 to 1.76:1.

Not routed through gl4es. gl4es keeps no copy of this enable and never sets it
itself, so disabling it once holds for the whole run, the engine's own rendering
through gl4es included.

Confirmed on the device: "sRGB write conversion off" in the log, and the picture
is correct - which means considerably darker. That is how the game is meant to
look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:45:20 -07:00
bearstonemandClaude Opus 5 6e86e55c08 chore(vr): do not ship the test harness switched on
The config that goes out had cheats enabled, every weapon handed to the player a
second after spawn, and a map loaded straight past the menu. That is a harness
for tuning weapon alignment without replaying a level each build, and it has no
business being the way anyone is handed the game.

cheats, vr_testStart and vr_startMap are now empty, with the working values kept
in a comment next to the explanation of why each one needs its own timing -
autoexec runs before there is a map, and a map loaded from autoexec is torn back
down by the game's own startup.

vr_captureEye off and vr_traceFrame back on, which are the normal values for
both.

vr_weaponAdjust keeps the value being tuned; it is a correction the weapon
genuinely needs, not a testing shortcut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:10:06 -07:00
bearstonemandClaude Opus 5 013b2f5bbf fix(vr): put in-game fullscreen menus on the panel, and load the test map late
Three things, all found by running it.

The mission briefing was drawn onto the player's face. VR_UseScreenLayer only
asked whether clc.state was CA_ACTIVE, so a fullscreen menu that comes up while
the player is still in the world skipped the quad layer and was drawn as screen
space 2D into both eye buffers - at the near plane, a hand's width away, with no
disparity to fuse and each eye warping it through its own asymmetric frustum.
That is exactly what the panel exists to prevent; nothing in-game was reaching
for it. It now checks UI_FullscreenMenuUp as well.

The log said so before the headset did: "view3d 0" with the eye passes still
running means the 3D scene is not being drawn at all, which only happens when a
fullscreen menu is covering it.

map in autoexec.cfg never had a chance. That file is exec'd during Com_Init and
the game's own startup - the intro stages, then the menu - runs afterwards and
tears down anything loaded there. vr_startMap now waits until the client is
sitting idle at the menu and loads it then. vr_testStart also prints
unconditionally: a hook that silently does nothing is indistinguishable from one
that never ran, and telling those apart has cost enough already.

vr_captureEye is a plain archived cvar rather than a VR_TuningCvar. Those force
their default at every start, which is right for a value still being argued with
and wrong for a switch - it made the config's own setting impossible to honour
and cost a device run to notice. The handoff warns about this exact trap.

vr_traceFrame is off in the shipped config: three lines a second rolls anything
that prints once out of logcat within a minute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:46 -07:00
bearstonemandClaude Opus 5 ce91fd1bf7 feat(vr): per weapon alignment, and a way to test it without replaying the level
The view model is authored to sit at the camera with the arms reaching forward,
so anchoring its origin at the controller leaves that built-in offset in place
and the gun lands near the player's elbow. Rotation was already correct; only
the position was wrong, which is exactly what this fixes and exactly why no
amount of getting the hand pose right removes the need for it.

Same mechanism as the reference's vr_weapon_adjustment_<id> (rtcw
cg_weapons.c, VrInputWeaponAlign.c), as one tunable cvar:

    vr_weaponAdjust "scale,right,up,forward,pitch,yaw,roll"

Two details from theirs that are easy to get wrong and are kept here. The
rotation correction is composed in the weapon's own frame rather than added to
the angles, which is only right for one axis at a time. And the offset is
applied along the corrected axes, after the rotation - that ordering is why a
model can rotate correctly and still sit in the wrong place. It is a cvar so it
can be dialled in by pushing a config and relaunching, with no rebuild in the
loop.

CL_RunStartupCommand runs vr_testStart once, a second and a half after the
player is in the world. Setting up a weapon test otherwise means playing through
to wherever the game hands one over on every single build; there is no console
in a headset to type "give" into, and autoexec runs before there is a map, let
alone a player to give anything to. The delay is because CA_ACTIVE arrives
before the player has finished spawning and a give aimed at nothing is dropped.
It re-arms on every map load rather than firing only once per run.

The reference keeps an adjustment per weapon. This is a single global one until
the numbers for each are known - which is what the harness is for.

Not yet run: the headset dropped its connection before this could be installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:39:04 -07:00
bearstonemandClaude Opus 5 db8dfae0bd feat(vr): reload on a tap of the weapon hand's grip
The reference's gesture (VrInputDefault.c): press and release the dominant grip
inside vr_reloadtimeoutms and it sends +reload, then -reload a frame later
because the game wants to see the key held for a tick before it is let go. A
long hold is their weapon wheel, which this port does not have, so holding
simply does nothing rather than reloading late.

The weapon hand grip was a second binding for use. Use stays on the right stick
click, which is where the reference has it too - +activate on ovrButton_Joystick
- so nothing is lost.

vr_reloadTapMs is their vr_reloadtimeoutms under this port's naming.

Built, not yet run: the headset has been unreachable since the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:12:06 -07:00
bearstonemandClaude Opus 5 046e2dd9a6 feat(vr): put the weapon in the player's hand, from the reference
Four pieces, each adapted from RTCWQuest rather than invented.

Aim. Weapon::GetMuzzlePosition derives a shot from player->m_vViewAng, which is
MOHAA's analogue of rtcw g_weapon.c:1904. It now takes the controller's pitch
and the yaw the hand leads the head by, added to the view yaw the game already
has - the controller's absolute yaw means nothing to the game, since the player
can snap turn on the stick and the play space has an arbitrary forward. Only the
trace moves; the camera stays on the head.

The angles reach fgame through gameImport_t rather than the usercmd, which has
no spare field - angles[3] is the view direction and buttons is packed. A
dedicated server answers false and keeps the aim it had.

Placement. The first person model is MOHAA's FPS view model, positioned by
CG_OffsetFirstPersonView against the camera and given the walk bob. Both are
wrong in a headset: the hand has its own position, and shaking the view model
when the camera is the player's head is a way to make people ill. It now goes
where the hand is - RTCWQuest's CG_CalculateVRWeaponPosition, including
dropping the origin to the feet and adding the measured head height back so the
weapon hangs at the height the player is really holding it, rather than off the
camera where crouching would drag it about.

Selection. Nothing was bound to it at all, and there is no button left - the
triggers fire, both face buttons on each hand are objectives and jump, and the
sticks and grips are duck and use. The reference does not spend a button either:
the dominant thumbstick up is weapprev and down is weapnext, gated on the
sideways component so it cannot be read as a turn and edge triggered so a held
stick does not run the inventory. This port's turn already ignores that stick's
Y axis, so nothing was displaced.

Two handed hold. Off hand grip squeezed with the hands close together points
the weapon along the line between them instead of along the weapon hand alone -
how a rifle is actually held, and steadier, because a wobble at one hand is
damped by the distance to the other. Roll is halved rather than taken from the
line, which carries none. The off hand grip was a duplicate of use; the weapon
hand's still opens doors.

The suggested binding array was 16 with 14 in use. Now 32.

Aim and placement are built but not yet confirmed on the device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:10:24 -07:00
bearstonemandClaude Opus 5 411c3d9bc2 docs: stop pointing at diagnostics that no longer exist
r_traceSurf and the r_noCull/r_noDepth overrides were removed with the bugs they
were built to find. The technique the doc is actually teaching - ask the driver,
not gl4es - stands on its own and is four lines to put back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:39:07 -07:00
bearstonemandClaude Opus 5 862d36a9ba chore(vr): take the diagnostics back out
Removes r_flatColor, r_traceSurf, r_forceGenericStage, r_noCull, r_noDepth,
r_noFog, r_invertCull, vr_debugPanel and its magenta panel clear, along with
R_TraceSurface, R_DriverGet, R_FlatColorPush/Pop and the GetFloatv/IsEnabled
entries added to qgl.h for them. Around 570 lines.

Everything they were built to find is fixed, and the fixes are all still here:
the cull face forced past gl4es's cache, RB_SurfaceFace's index type, the window
resize vid_restart, the frustum built from the runtime's tangents, the terrain
cone sized to the headset, and the VR layer's state calls routed through gl4es.
Verified rendering on the device after the strip, with no overrides at all.

vr_captureEye stays, defaulted off. It writes the eye buffer to a TGA and it is
the one probe that consistently earned its keep - a picture settled in one look
what rounds of description could not. Inert unless switched on.

misc/android/autoexec.cfg is down to that single line. Nothing in it is load
bearing any more.

Also records a third instance of the session's recurring mistake in the gl4es
doc: r_noDepth was declared, registered and tested for several device runs while
the edit meant to read it had silently failed to apply. Every result attributed
to it was a no-op, and it sat in the "known good" configuration taking credit for
what disabling culling was actually doing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:38:40 -07:00
bearstonemandClaude Opus 5 11b1597f14 docs: write up the gl4es boundary, which is where the days went
Three bugs on this port presented identically - geometry submitted, vertex data
correct, indices in range, matrices correct, on screen, unclipped, no GL error,
nothing drawn - and all three were gl4es state calls being dropped. The
framebuffer binding, the viewport, and finally the cull face, which cost most of
a session on its own.

The underlying fact is that gl4es keeps its own copy of GL state. It answers
glGetIntegerv for GL_CULL_FACE_MODE, GL_FRONT_FACE and GL_MODELVIEW_MATRIX from
its own tables rather than the driver, so reading state back through qgl and
comparing it against the engine compares the engine with a copy of itself and
agrees by construction. And it returns early from glCullFace, glDepthMask,
glDepthFunc, glBlendFunc, glColorMask, glViewport, glScissor and several
glEnable caps whenever the request matches that copy - so once it drifts, the
engine can never correct it. It asks for the right value, gl4es calls it
redundant, the driver never hears, and nothing raises an error.

docs/markdown/04-coding/04-gl4es.md records what to do about it: resolve
glGetIntegerv out of libGLESv3 to ask the hardware; route the VR layer's state
calls through gl4es, with the three exceptions that must not be; and where a
value has to land regardless, name a throwaway first so the real one cannot
match a stale cache.

It also records the two rules that are not about gl4es and cost as much: a probe
must be shown to reach the thing it measures - three of ours had blind spots
exactly where the fault was - and look at the frame, because one capture settled
what six rounds of description could not.

Linked from the Android build doc, since that is where someone hitting this will
be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:30:18 -07:00
bearstonemandClaude Opus 5 5e7e907792 fix(renderer): force the cull face past gl4es's cache, and the world appears
gl4es keeps its own copy of the cull face and returns early from glCullFace
whenever the requested mode matches it (src/gl/face.c:14). Measured on the
device, past gl4es and straight out of libGLESv3, its copy said GL_FRONT while
the driver was culling GL_BACK. So the engine asked for GL_FRONT every frame,
gl4es judged that redundant against a copy that had drifted, and the hardware
went on removing exactly the faces Quake wants kept - its world polygons are
back facing in GL's terms, which is why CT_FRONT_SIDED culls GL_FRONT at all.

The symptom was the entire world invisible while models rendered perfectly,
because model shaders are CT_TWO_SIDED and never enable culling. Inverting the
culled face did not help either, because asking for GL_BACK matched what the
driver was already doing. Only disabling culling outright brought the world
back, which is what made this look like a winding fault for so long.

GL_Cull now names GL_FRONT_AND_BACK first, so the mode it actually wants cannot
be mistaken for a no-op. Nothing is drawn between the two calls.

With this, and with the vid_restart and RB_SurfaceFace index fixes, the world
renders with no overrides at all: terrain, BSP, models, lightmaps, fog and sky,
at 89-90 fps and 6 ms per eye against rend2's 50-60.

Handoff section 9 is rewritten around what this cost to find: gl4es answers
glGetIntegerv from its own tables, so every "GL and the engine agree"
measurement taken through qgl compared the engine against a copy of itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:28:56 -07:00
bearstonemandClaude Opus 5 e967529ec6 fix(renderer): ask the driver for GL state, not gl4es's copy of the question
gl4es answers glGetIntegerv for GL_CULL_FACE_MODE, GL_FRONT_FACE and
GL_MODELVIEW_MATRIX out of its own tables (src/gl/getter.c), the same way it
answers glGetString from constants - the trap section 5.5 already records. Two
measurements in this session read back "GL" state through qgl, compared it
against the engine, found it identical, and concluded the state was correct.
They were comparing the engine against gl4es's copy of the engine. That agrees
by construction and proves nothing, and both conclusions drawn from it were
worthless.

It matters because gl4es also drops calls its cache thinks are redundant -
glCullFace returns early when the mode already matches (src/gl/face.c:14), as do
glDepthMask, glDepthFunc, glBlendFunc, glColorMask, glScissor and glViewport. So
the driver can hold something the engine never asked for while gl4es reports the
engine's own value straight back at it.

r_traceSurf now resolves glGetIntegerv and glIsEnabled out of libGLESv3 directly
and prints the driver's answer next to gl4es's. The open question it is aimed at:
world geometry disappears with backface culling enabled whichever face is named,
while CT_TWO_SIDED models are unaffected. No winding fault does that, but a
driver cull mode of GL_FRONT_AND_BACK would, and gl4es would still report
GL_FRONT.

Not tested on the device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:02:06 -07:00
bearstonemandClaude Opus 5 5267bc66c0 fix(vr): size the terrain visibility cone to the headset, not the game's fov
MOHAA decides which terrain patches are visible with a cone test:
R_MarkTerrainPatch rejects anything further off the view axis than
g_fClipDotSquared allows, and that value is a table lookup indexed by
tr.refdef.fov_x. At the game's 80 degrees the half angle is about 46.

In VR nothing else reads fov_x any more. The projection is built from the
runtime's four tangents in R_SetupProjection, and the culling frustum from the
same in R_SetupFrustum. So refdef.fov_x stayed at whatever the game asked for
while a Quest 3 eye sees closer to 100 degrees, and terrain that was inside the
headset's view but outside an 80 degree cone was never marked visible and never
drawn - with nothing anywhere reporting it, because that cvar has no other
consumer left in this path.

The fov now comes from the widest horizontal tangent when a headset view is
active. Widest rather than the sum: the per-eye frustum is asymmetric and the
cone is symmetric about the view axis, so it has to cover the wider side.

The index is also clamped away from zero. g_fDistanceTable[0] is
443.5 / sqrt(1 - 1) - infinity - which poisons g_vClipOrigin and culls every
patch in the level. Not reachable today, but nothing checked, and the failure
would be total and silent.

MOHAA specific: RTCWQuest has no terrain, so the reference could not have caught
this one.

Not yet tested on the device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:46:53 -07:00
bearstonemandClaude Opus 5 a3e3d04a5a build(vr): keep the device config in the repo, with what it is working around
misc/android/autoexec.cfg is the only way to set a cvar on a headset - there is
no console, and the file is exec'd after omconfig.cfg and before R_Init, so it
beats both the stored config and the renderer's own defaults, latched and
archived cvars included.

It currently carries r_noCull 1 and r_noDepth 1, which the world does not draw
without. Those are workarounds, not fixes, and the file says so at the point
someone will read it. It lives next to the game data rather than in the APK, so
reflashing the data loses it - which is the reason to keep a copy here.

Section 10 also records the traps that cost round trips: adb install -r leaves
the old process running so a run can test the previous build; a cvar left out of
the config is whatever omconfig.cfg last archived, which is how a shadows test
came to test nothing; and pulling the data directory for a screenshot moves 1.6
GB of pk3s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:38:42 -07:00
bearstonemandClaude Opus 5 b49e34a9ed fix(vr): stop the window resize restart, and the indices the world was drawn with
Two bugs, both found on the device, both with the evidence recorded in
VR_RENDERER_HANDOFF.md sections 8 and 9.

The menu was a renderer restart. SDL reports a window resize; the handler
compares it against glconfig.vidWidth/Height, which in VR is the eye buffer and
never the Android surface, so it can never match. It set r_customwidth,
r_customheight and r_mode -1 - cvars the renderer then ignores, because the VR
resolution comes from GetVRRenderResolution - and scheduled a vid_restart that
destroyed and rebuilt the GL context and the OpenXR session under a running
frame loop. The panel drew a full 256/256 samples up to CL_Vid_Restart_f and
nothing ever after. VR_PORT_STATUS called that restart harmless; it was the
whole bug.

RB_SurfaceFace declared its output pointer as unsigned* and assigned
tess.indexes to it, which is glIndex_t* - unsigned short under gl4es. Every
index went in four bytes apart in a two byte array, so each triangle picked up
whichever vertex the zero high halves pointed at. That was the world smeared
into streaks that dragged its textures with it; models never come through that
function, which is why they were always correct. ProjectDlightTexture had the
same fault in hitIndexes. The compiler had been reporting it all along, under
sixty other warnings in that file.

Also: renderergl1 culled against the game's symmetric fov rather than the
runtime's asymmetric per-eye frustum; the VR layer set viewport, cull, depth,
blend, colour mask and scissor state natively while gl4es caches all of them and
drops the engine's next request for a value its stale copy already claims; and
GfxInfo_f reported a different draw route than R_DrawElements took.

The remaining diagnostics are CVAR_TEMP and default off. vr_captureEye is worth
keeping: it writes the eye buffer to a TGA, and one image settled questions that
six rounds of verbal description could not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 02:29:43 -07:00
bearstonemandClaude Opus 5 27011bd3eb docs: hand off the renderer swap with what is proven and what is not
renderergl1 on gl4es runs on the headset at 90fps and draws the engine's own
geometry - the intro logo screen renders under a flat colour override. The main
menu still draws nothing, and that is the one open problem.

The largest blocker turned out to be the gl4es build itself: with CMake's flags
it cleared framebuffers perfectly and rendered no geometry whatsoever, measured
as 144 red from a clear and 0 green from a quad into the same buffer moments
later. Matching RTCWQuest's Android.mk fixed it outright with no engine-side
change. gl4es's own CMakeLists adds -DANDROID under the NDK toolchain and
ndk-build never does, so the reference has only been exercised without it, and
it gates the code that decides what the fixed function pipeline may emit.

Recorded at length because it invalidated most of a session's work: while gl4es
could not draw, every experiment ending "and nothing was drawn" returned the
same answer whatever it was testing. Four changes were adopted on the strength
of such results and are now marked for re-testing rather than trusted. The
document says plainly which findings are measured and which are not.

Also commits the framebuffer helpers the VR layer needs: gl4es has to own the
GL_FRAMEBUFFER binding or it renders from its own table into a 16x16 pbuffer,
while the swapchain texture attachment and the blit's read and draw bindings
must stay with the driver - routing those through gl4es empties the blit and
takes the cutscenes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:48:49 -07:00
bearstonemandClaude Opus 5 1249e897c6 build(vr): build gl4es the way the reference builds it
renderergl1 on gl4es reaches the headset, clears the eye and panel buffers
correctly, and renders no geometry at all - immediate mode and vertex arrays
alike, with correct geometry, correct matrices, a complete framebuffer both
layers agree on, and no GL error on any draw. Measured as 144 red from a clear
and 0 green from a quad drawn immediately afterwards into the same buffer.

Since RTCWQuest runs this same stack on this same device, this makes our gl4es
identical to theirs rather than to CMake's defaults. From their Android.mk:

    -DBCMHOST -DNOX11 -DNO_GBM -DDEFAULT_ES=2 -DNO_INIT_CONSTRUCTOR
    -O3 -fcommon -fvisibility=hidden -funwind-tables

ANDROID is deliberately not among them. gl4es's own CMakeLists adds it whenever
the NDK toolchain is in use and ndk-build never does, so the reference has only
ever been exercised without it - and it is not cosmetic: it selects different
code in the loader, in glx, and in hardext, which is where gl4es compiles a
probe shader to decide what its fixed function pipeline may emit. It comes from
add_definitions(), a directory property, so it is removed where it was set
rather than overridden on the target.

-fcommon matters for a different reason: gl4es is old C that expects tentative
definitions to merge, clang has defaulted to -fno-common since 15, and this NDK
is well past that while the reference's toolchain is not.

BCMHOST is the Raspberry Pi switch and looks wrong here; everything it gates is
additionally guarded by !defined(ANDROID). Carried because matching a build that
demonstrably works matters more than tidiness.

This is convergence on a known-good configuration, not a diagnosis. If it
renders, bisect the flags to find which one mattered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:36:03 -07:00
bearstonemandClaude Opus 5 6e369175be docs: record what the gl4es boundary actually requires
Three findings that each cost a device run: gl4es cannot bind a framebuffer it
did not create and fails silently when asked, so the renderer must bind nothing
and only flush; gl4es answers glGetString from constants, which defeats the
engine's only check that a context is live; and SDL unbinds the context and
reports success when the window has no EGL surface, which in a headset is always.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:12:45 -07:00
bearstonemandClaude Opus 5 17771047c5 fix(vr): stop binding the eye framebuffer through gl4es
The headset was black while the frame loop ran at a contented 90 fps and the
audio played. Everything was being drawn into the window nobody looks at.

Routing the renderer's glBindFramebuffer through gl4es - so that gl4es would see
every call the renderer makes - is right in general and wrong for this call in
particular. gl4es can only bind framebuffers it created itself: its
glBindFramebuffer looks the name up in its own table, and every framebuffer the
VR layer owns comes from the driver's glGenFramebuffers. The lookup misses, it
raises GL_INVALID_VALUE, returns without binding, and goes on believing
framebuffer 0 is current. So the eye redirect quietly did nothing.

RTCWQuest's renderer never binds a framebuffer at all - Quake 3's fixed function
path predates them - and TBXR_Common binds the eye directly, leaving gl4es to
draw into whatever the driver has. renderergl1 is the same renderer and this now
does the same thing.

What the renderer is still told about is the flush. gl4es batches geometry and
issues it lazily, so work built for one eye would otherwise arrive in whichever
target is bound when it finally goes out. RE_SetDefaultFramebuffer flushes while
the old target is still current and binds nothing, which is also why the VR
layer calls it before its own bind rather than after.

readfboBegin and readfboEnd early out while gl4es's read and draw framebuffers
are the same, which they always are here, so gl4es does not rebind behind this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:12:04 -07:00
bearstonemandClaude Opus 5 d9960e1857 fix(vr): bind the engine's GL context against a pbuffer, as RTCWQuest does
The OpenXR session could not be created: eglGetCurrentContext returned nothing
while SDL was certain a context was current.

SDL binds the GL context against the window's EGL surface. In a headset there is
no window being presented - every pixel reaches the display through the
swapchains - so that surface never arrives. What SDL does then is the trap.
SDL_EGL_MakeCurrent, handed no surface and without gl_allow_no_surface, calls
eglMakeCurrent(EGL_NO_SURFACE, EGL_NO_CONTEXT) to unbind everything and returns
success; SDL_GL_MakeCurrent then records the context as current. So SDL's
bookkeeping and EGL's actual state disagree permanently, and asking SDL to bind
it again does nothing, because SDL_GL_MakeCurrent sees its own record agree and
returns early.

RTCWQuest never meets this because it never depends on a window surface: it
makes its context current against a 16x16 pbuffer (TBXR_Common.c,
egl->TinySurface). Same here, except the context is the one SDL already made,
since the engine renders in it. The EGLConfig comes from eglQueryContext, which
VR_CreateSession was already recovering for the graphics binding.

The pbuffer outlives the session deliberately - the session is torn down and
rebuilt around renderer restarts, and unbinding the context in the middle of
that is the thing this prevents. It is released in VR_Shutdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:08:14 -07:00
bearstonemandClaude Opus 5 a8a1314c2d fix(vr): ask for an ES context for the fixed function renderer, and check it is current
Two problems the first device run found, one hiding the other.

GLimp_SetMode's fixedFunction branch offers exactly one context: desktop OpenGL
1.1. There is no such thing on Android, so window creation failed three times
over and R_Init gave up with "Couldn't get a visual". renderergl2 never met this
because it takes the other branch, which already knows how to ask for ES.

gl4es is what makes the request answerable, and it wants the opposite of what
the renderer believes it is getting: desktop GL 1.x presented upwards, OpenGL ES
underneath. So the fixed function branch now asks for ES 3.0 and then ES 2.0,
and the renderer is never told - it reads its version through gl4es, which
reports desktop GL, and takes the desktop fixed function path on the strength of
it.

That got the renderer up, and revealed the second problem: the OpenXR session
found no current EGL context. Which is where gl4es having its own opinion about
glGetString stops being convenient. The engine's proof that a context is live is
GL_VERSION coming back non-NULL, and gl4es answers VERSION, VENDOR and RENDERER
out of constants without asking the driver - so a context that was created but
never bound passes that test and is only noticed much later, somewhere else
entirely. GLimp_SetMode now asks SDL directly whether the context it just made
is current, restoring the check gl4es defeated.

The session no longer assumes one is bound either. Being current is per thread,
and a window that has not been shown yet has no surface to be current against,
so VR_CreateSession asks for the binding through GLimp_MakeCurrent rather than
requiring it to already be there, and reports the EGL handles when it cannot get
one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 22:02:44 -07:00
bearstonemandClaude Opus 5 aae8dff0f2 docs: correct the renderer handoff against what was actually true
Section 4 claimed renderergl1 compiled clean. It had never compiled at all -
android.cmake forced the renderer choice into the cache with CACHE INTERNAL,
which implies FORCE, so the command line was overwritten every configure.

Section 5.1 asked whether the gl4es target was real. It was, and it was
building; the artifact went into the engine's source tree because gl4es points
CMAKE_LIBRARY_OUTPUT_DIRECTORY at ${CMAKE_SOURCE_DIR}/lib and FetchContent makes
that the top-level project. RENDERER_LIBRARIES reached the client the whole
time.

Both of those turned out to be the wrong question anyway, which is the part
worth keeping: renderergl1 does not call GL by name. Every entry point is a
function pointer filled in from SDL_GL_GetProcAddress, and on EGL 1.5 SDL asks
eglGetProcAddress first - so linking gl4es correctly would still have left the
driver answering for every name GL 1.x and ES share.

Records what is now settled about gl4es alongside the VR layer, and what is
still only suspected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:48:41 -07:00
bearstonemandClaude Opus 5 d2d52a5af4 fix(vr): tell the renderer before moving the framebuffer under it
The VR layer bound each eye, the UI buffer and the wrist panel directly and
then told the renderer afterwards. That order was harmless while the renderer
issued every call as it made it. It is not harmless under gl4es, which batches
geometry and flushes lazily: it only learns to flush when something it knows
about changes the target, so a batch left over from one pass would arrive in
whatever was bound next - the previous eye's contents showing up in this one.

Routing the transition through re.SetDefaultFramebuffer first gives gl4es the
chance to flush into the target the geometry was drawn for. The direct bind
stays where it was needed, since the renderer only binds framebuffers when its
own framebuffer support is on; it is now a no-op against the same target.

Also points the Android docs at renderergl1 with gl4es under it, and records
that libgl4es.so rides into the APK as a dependency of libopenmohaa.so rather
than being loaded by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:46:20 -07:00
bearstonemandClaude Opus 5 6f34f4524c feat(vr): put the headset behind the fixed function renderer
Everything the VR work had built so far lived in renderergl2. This carries it
across to renderergl1, which is the renderer the port is moving to.

Most of it transfers unchanged: the head pose composed onto whatever camera the
game asked for (R_ApplyVRView, in RE_RenderScene so it follows cutscenes and
ladders as well as the player), the asymmetric per-eye frustum and vr_fovZoom in
R_SetupProjection, the eye render resolution asked for through
GetVRRenderResolution rather than r_mode, and the sprite paths that face the
viewer's position rather than the view plane.

Two places came out simpler than rend2. Everything 2D in this renderer goes
through Set2DWindow - RB_SetGL2D is a one line call to it - so the HUD's
convergence has a single place to be applied rather than two that had to be
kept in agreement. And RE_SetDefaultFramebuffer is nearly nothing: renderergl1
predates framebuffer objects, has none of its own and never touches the
binding, so redirecting an eye is one bind with no FBO_Bind to intercept and
nothing to restore.

glBindFramebuffer is resolved on first use rather than declared with the rest
of qgl, because renderergl1 has no extension probe of its own - it goes through
GLimp_GetProcAddress so it reaches gl4es like everything else the renderer
calls, and gl4es stays aware of which framebuffer is bound.

renderergl2 still builds and is unchanged in behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:38:59 -07:00
bearstonemandClaude Opus 5 633221c57d build(vr): actually build renderergl1 on Android, and route its GL through gl4es
The gl4es work was in the tree but nothing had exercised it. Three things were
wrong, each hiding the next.

cmake/platforms/android.cmake forced BUILD_RENDERER_GL1 OFF and GL2 ON into the
cache. CACHE INTERNAL implies FORCE, so the -D on the command line was
overwritten on every configure and renderergl1 had never once compiled - the
build that appeared to succeed was a renderergl2 build with gl4es sitting unused
beside it. The renderer choice goes back to the top level options, which already
default the way this port wants.

gl4es's own CMakeLists points CMAKE_LIBRARY_OUTPUT_DIRECTORY at
${CMAKE_SOURCE_DIR}/lib, which under FetchContent is this project's source root,
so the artifact was written into the engine's source tree. It also builds itself
as libGL.so.1 after the fashion of a system OpenGL, and Android has no versioned
sonames - the package manager extracts only lib*.so out of an APK. Redirected
into the build tree as libgl4es.so and staged with the other APK libraries.

With that fixed renderergl1 does compile for arm64, but it would not have
rendered. Its entry points come from GLimp_GetProcAddresses via
SDL_GL_GetProcAddress, and on EGL 1.5 - which the Quest is - SDL asks
eglGetProcAddress first. The driver answers for every name OpenGL 1.x and ES
have in common, so glEnable, glBindTexture and eighty more would have bound to
Adreno while glBegin and glMatrixMode bound to gl4es. gl4es would then have been
batching geometry and tracking a matrix stack against calls the driver was never
told about. The renderer now resolves against the gl4es handle directly, and
calls initialize_gl4es since NO_INIT_CONSTRUCTOR is set. The extension string
had the same problem and the same fix: read through SDL it is the driver's ES
list, which names none of the desktop extensions the renderer asks after, so
GL_ARB_multitexture read as absent and the renderer would have quietly dropped
to one texture unit.

The VR layer keeps its own direct GLES linkage; only the renderer is moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:26:22 -07:00
bearstonemandClaude Opus 5 7f794abbb4 docs: record where the VR port stands
A companion to VR_PORT_PLAN.md for picking the work up cold: what is
built, the deployment traps, how the VR layer fits together, and what to
do next.

It also records where the plan turned out to be wrong, which is the part
worth reading first. The renderer did not in fact start on any OpenGL ES
context; framebuffer objects were switched off on the ES path despite
being core there; there is no SDL audio path to fall back on; and static
linking the game modules is not possible. Each of those shaped the port
differently to how the plan assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:22:36 -07:00
bearstonemandClaude Opus 5 7872929f32 feat(vr): head aim, stick locomotion and comfort options
The game now knows where the player is looking. Until now head tracking
only moved the camera the renderer drew from, so anything consulting the
player's view angles - objectives that ask them to look at something, aim,
hit detection - never saw it. CL_VRMove writes the head's heading and
pitch into cl.viewangles, which is what all of that reads.

Walking follows the off hand rather than the gaze, after the approach in
Team Beef's RTCWQuest. Tying travel to where the player is looking means
they cannot glance sideways without veering, which is what makes room
scale movement feel like being dragged; steering with the free hand lets
them look around and still walk a straight line. cl_vrWalkWithView
restores gaze-directed movement.

Turning snaps by default. A stick that rotates the world smoothly while
the inner ear insists otherwise is the usual way to make people ill, and
one flick gives one step rather than one per frame.

  cl_vrSnapAngle    45    degrees per flick
  cl_vrSmoothTurn   0     1 for smooth turning instead
  cl_vrTurnSpeed    90    degrees/second when smooth
  cl_vrDeadzone     0.2
  cl_vrWalkWithView 0     1 to walk where the player looks

All archived, so they are tunable on the headset rather than at build
time, which is what section 5 of the porting plan asks for.

Thumbsticks are bound on the Touch profile only: the simple controller
has none, and offering a binding a runtime does not recognise makes it
reject the whole set, which would cost the fallback its pointer too.

The dominant hand's aim angles are carried through vrInput_t but not yet
used. Aim is still on the head, which section 5 is right to call wrong
for a shooter - that belongs with weapon handling, and looking at things
had to work first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:21:14 -07:00
bearstonemandClaude Opus 5 393b0e3e3d fix(vr): make billboards face the viewer rather than the view plane
Quake's autosprites and MOH:AA's sprite imposters both orient themselves
by adopting the camera's own axes, so every one of them sits parallel to
the view plane. On a monitor that is indistinguishable from facing the
viewer, because the screen is the view plane. In a headset it is not:
turning the head swings every billboard in the level round in unison,
and they visibly spin in place.

They now take the direction from their own position to where the viewer
actually is, built against world up so they stay upright instead of
inheriting the head's roll. Head rotation no longer affects them; only
moving does.

Covers AutospriteDeform, and SPRITE_PARALLEL and SPRITE_PARALLEL_UPRIGHT
in the sprite path - the latter being the foliage imposters that stand in
for static models at a distance, which is why near foliage looked correct
while distant foliage did not.

Still outstanding: some foliage continues to rotate, so at least one more
path orients this way. Left for when movement makes it easier to tell
which effects are misbehaving and why. Autosprite2Deform is untouched: it
pivots around a fixed axis for beams and rails, so it is far less
sensitive to this, and changing it blind would risk breaking effects that
currently look right.

All of this is conditional on a VR view being active, so the flat
renderer is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:12:26 -07:00
bearstonemandClaude Opus 5 5aff8d336a feat(vr): stereo rendering and 6DoF head tracking through OpenXR
Runs on a Meta Quest 3 in stereo at the headset's native 1680x1760 per
eye, with the world tracking head movement in six degrees of freedom.

The session binds to the EGL context SDL already created rather than a
private one, so there is a single GL context and the renderer needs no
thread or context juggling. Each eye is rendered into an OpenXR swapchain
image by substituting the framebuffer the renderer treats as the screen,
which leaves the whole render path untouched. Exactly two eye passes per
frame; there is deliberately no third pass to produce a flat image.

The session and its swapchains belong to the GL context they were made
with, so they are torn down and rebuilt around the renderer restarts the
engine performs.

Menus, the loading screen and cinematics go on a quad layer fixed in the
room, placed once in front of wherever the player is facing. Drawing
screen space content into both eyes instead gives it no stereo disparity
while each eye still warps it through its own asymmetric frustum, and the
result cannot be fused. The panel is drawn into one persistent buffer
that is then copied into the swapchain image: a swapchain rotates through
several images, and the engine's 2D path expects the screen to still hold
what it drew last frame.

Head tracking:

- The per-eye asymmetric frustum from xrLocateViews feeds
  R_SetupProjection directly. A symmetric approximation misaligns the two
  images by a few degrees, which the eyes cannot fuse.
- The pose is applied in RE_RenderScene, so it follows every camera the
  game has - the player's, cutscenes, ladders, death animations - without
  each needing to know about VR.
- Orientation is carried as axes rather than pitch/yaw/roll, and composed
  with the game's heading as a rotation. Adding Euler angles only works
  while pitch and roll are near zero and turns every head movement into a
  twist once they are not.
- Position is measured from where the head was when tracking started, so
  the game keeps supplying eye height and the headset contributes only
  leaning, ducking and stepping. The initial heading is subtracted too,
  or the play space's arbitrary forward is added to the character's and
  the player spawns facing the wrong way.

The controllers point at the panel and click: the aim ray is intersected
with the quad and the hit becomes the engine's cursor position, so the
menus behave as they always did.

The renderer's own framebuffer objects stay off on Android. Each eye is
already a swapchain image, so routing through tr.renderFbo first would
cost a second full resolution pass and resolve per eye for nothing.

Known gaps: head orientation does not yet reach the player's view angles,
so the game does not know where the player is looking; and there is no
controller model or pointer ray.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:03:38 -07:00
bearstonemandClaude Opus 5 eea9112be9 feat(android): add Android platform support
Builds an APK for arm64-v8a, brought up on a Meta Quest 3 but not
specific to it. The engine reaches the main menu with retail data
side-loaded and renders through renderergl2 on an OpenGL ES 3.2 context.

Build system: a new cmake/platforms/android.cmake, driven by the NDK's
android.toolchain.cmake. renderergl1 is not built (it is fixed function
and would need a GL-over-GLES translation layer), the renderer is linked
into the client rather than dlopen'd, and SDL2 and OpenAL Soft are built
from source since neither ships an Android binary. OpenAL is not
optional here: it is the engine's only sound driver, so USE_OPENAL=OFF
does not build. Native libraries are staged per-ABI for gradle, which
packages them in misc/android.

Portability fixes for bionic:

- pthread_cancel does not exist; GameSpy's async DNS thread detaches
  instead.
- <execinfo.h> does not exist. sys_android_new.c walks the stack with
  _Unwind_Backtrace and resolves it through dladdr, writing straight to
  logcat. This matters because tombstoned does not reliably report a
  backtrace, leaving no account of where the engine died.
- librt does not exist; those symbols live in libc.
- libmumblelink.c needs shm_open and was being compiled even with
  USE_MUMBLE off. It now builds only when Mumble is enabled.

con_android.c sends the console to logcat and pumps stdout and stderr
there too, so output from the C library and from third party code is not
silently discarded.

The fatal signal handlers move ahead of Com_Init. Startup is exactly
when renderer and filesystem bring-up crash on a new platform, and a
fault there previously died with no backtrace at all.

Game modules ship inside the APK as libgame.so and libcgame.so and are
loaded off the linker's search path. Static linking is not an option:
cgame and fgame both compile q_shared, q_math and the script system, so
linking them into one binary collides. Android extracts only lib*.so
from an APK, hence DLL_PREFIX in q_platform.h.

Paths point at the app's external files directory, which is where the
user side-loads the game data and the only place the engine can write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:19:30 -07:00
bearstonemandClaude Opus 5 f3ca623053 fix(renderer): let renderergl2 initialise on OpenGL ES contexts
GLimp_GetProcAddresses() failed on every OpenGL ES context, so the GL2
renderer could not start on any ES target. Two independent causes:

QGL_1_1_PROCS carried five entry points that exist in no version of
OpenGL ES - glCallList, glDrawPixels, glFogi, glLineStipple and
glPixelZoom. That list is shared by the desktop and ES paths, so
requiring them there failed the whole load. Upstream keeps ES-incompatible
functions in QGL_DESKTOP_1_1_PROCS; these are moved there to match. Only
renderergl1 calls them - every use in renderergl2 sits inside an
"#if 0 // FIXME: unimplemented (GL2)" - so nothing loses a function it
actually invokes.

The ES 3.0 context was also only ever requested under __EMSCRIPTEN__.
Everywhere else the highest ES context asked for was 2.0, and since EGL
returns a genuine ES 2.0 context when that is what was requested, ES 3
was never reached even on hardware that supports it. It is now requested
first on all platforms.

Verified on a Meta Quest 3 (Adreno 740): renderergl2 now comes up on an
ES 3.2 context and compiles all 115 GLSL shaders. Desktop builds of both
renderers are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:19:10 -07:00
bearstonemandClaude Opus 5 6517d02dfe Add the Quest 3 VR porting plan
Survey of the tree plus a staged plan: Android build first, then flat
playable on the headset, then OpenXR stereo, then the VR interaction
design.

The finding that shapes the whole approach is that renderergl2 already
speaks OpenGL ES, inherited from upstream ioquake3 - qglesMajorVersion
is a real runtime variable, tr_glsl.c emits "#version 300 es" and
injects precision qualifiers, tr_extensions.c branches on it, and
sdl_glimp.c has an r_preferOpenGLES cvar that requests an ES context.

That is a much better starting position than it might have been. A
comparable Quest port of Homeworld needed gl4es to emulate
fixed-function GL 1.x over GLES2, and every later constraint followed
from that. Here renderergl2 can be driven at GLES 3 directly: no
translation layer, real shaders, and FBOs already first class in
tr_fbo.c - which matters for stereo, since rendering each eye into its
own framebuffer is exactly the piece the Homeworld port never built and
whose absence blocked MSAA there.

renderergl1 is treated as out of scope for the same reason: it is fixed
function and would need the emulation layer we are avoiding.

Also records the deployment traps from that earlier port - the silent
2770 directory ownership that locks the app out of its own data, the
chmod-after-push ordering, never launching over adb, and verifying that
crash logging works before trusting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:27:31 -07:00
107 changed files with 16912 additions and 415 deletions

No files matched your search

+52 -18
View File
@@ -1,39 +1,73 @@
---
name: Bug report
about: Create a report to help us improve
name: OpenMoHAA VR bug report
about: Report a VR, gameplay, rendering, or performance issue in this port
title: ''
labels: bug
assignees: ''
---
Report port issues at https://github.com/bearstonem/openmohaa-vr/issues.
Meta Quest is the current standalone release target. Steam Frame is a development
target; no Steam Frame binary is available yet. Please identify the build you tested.
**Describe the bug**
A clear and concise description of what the bug is.
What went wrong? For visual issues, describe whether it affects one eye or both,
the world, HUD, menus, or controller models.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
1. Start the game using '...'
2. Load mission/map or multiplayer mode '...'
3. Use these controls or settings: '...'
4. Observe '...'
Does it happen every time? Did a previous port release work?
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots (OPTIONAL)**
If applicable, add screenshots to help explain your problem.
**Headset and build**
- Headset model:
- Headset OS / firmware version:
- OpenXR runtime and version, if known:
- Controllers and input mode:
- OpenMoHAA VR release / downloaded asset name:
- Git commit and build configuration, if built from source:
- Engine version from the startup log or `version` console command:
- Host OS and GPU, if applicable to a development build:
**Game and settings**
- Game edition: Allied Assault / Spearhead / Breakthrough:
- Mission/map and single-player or multiplayer:
- Mods or custom assets:
- Changed VR/render settings and whether the release's `autoexec.cfg` was installed:
- For performance issues: refresh rate, render scale, and measured frame time/FPS, if available:
**Screenshots or recording (optional)**
Attach a short capture when useful. Describe headset-only symptoms that a recording
does not show, such as double vision, flickering in one eye, or tracking drift.
**Logs**
- Paste the output of the console window in a text file, and attach the text file.
- If applicable (`logfile` set to 2), attach the `qconsole.log` file. On Windows, it can be found in **%APPDATA%\...\qconsole.log** and on Linux, it can be found on the home directory **~/.openmohaa/.../qconsole.log**.
**Version information (please complete the following information):**
- OS: [e.g. `Debian`]
- OS Version [e.g `12`]
- OpenMoHAA Version: [e.g `0.70.0-alpha+0.0b1a20dcf win_msvc64-x86_64-debug`, can be found below **Common Initialization** in the console, or by typing `version`]
- Attach the startup log and the output around the failure as a text file.
- On Quest, start log capture before reproducing: `adb logcat -v threadtime -s openmohaa:V AndroidRuntime:E > openmohaa-vr.log`.
- Include `qconsole.log` when available (`logfile 2` enables it), plus crash output or a backtrace for crashes.
- For source builds on other targets, include relevant OpenXR/runtime logs and the exact launch command.
- Remove personal information or server passwords before attaching logs.
**Dump (OPTIONAL)**
You can include a core dump generated by the OS (`.dmp` file). If you compiled OpenMoHAA yourself, also add all output openmohaa binaries (except libopenal and libSDL).
**Crash dump (optional)**
If available, attach a crash dump or Android tombstone. For a source build, identify
the matching commit and retain its binaries/debug symbols for follow-up debugging.
**Additional context (optional)**
**Additional context (OPTIONAL)**
Add any other context about the problem here.
+2
View File
@@ -1,4 +1,6 @@
build*
!/misc/android/build.gradle
!/misc/android/app/build.gradle
*.swp
*tags
*~
+6 -1
View File
@@ -1,6 +1,11 @@
# Changelog
All notable changes to this project will be documented in this file.
For **OpenMoHAA VR** changes, see the [VR release notes](https://github.com/bearstonem/openmohaa-vr/releases)
and [port status](VR_PORT_STATUS.md). Quest is the current release target;
Steam Frame integration is in development.
The changelog below is inherited from upstream OpenMoHAA and records engine
changes. Its release links and version numbers belong to the upstream project.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+4
View File
@@ -14,6 +14,9 @@ project(${PROJECT_NAME} VERSION ${PROJECT_VERSION} LANGUAGES C CXX)
option(BUILD_SERVER "Build dedicated server" ON)
option(BUILD_CLIENT "Build client" ON)
option(BUILD_RENDERER_GL1 "Build GL1 renderer" ON)
# The original fixed function renderer needs a translation layer to reach
# OpenGL ES; see cmake/libraries/gl4es.cmake for why it is worth having.
option(USE_GL4ES "Translate the GL1 renderer's fixed function calls to OpenGL ES" ${ANDROID})
option(BUILD_RENDERER_GL2 "Build GL2 renderer" OFF)
option(BUILD_GAME_LIBRARIES "Build game module libraries" ON)
option(BUILD_GAME_QVMS "Build game module qvms" ON)
@@ -21,6 +24,7 @@ option(BUILD_STANDALONE "Build binaries for standalone games" OFF)
option(USE_RENDERER_DLOPEN "Dynamically load the renderer(s)" ON)
option(USE_OPENAL "OpenAL audio" ON)
option(USE_OPENXR "OpenXR stereo rendering and tracking" OFF)
option(USE_OPENAL_DLOPEN "Dynamically load OpenAL" ON)
option(USE_HTTP "HTTP download support" ON)
option(USE_CODEC_VORBIS "Ogg Vorbis support" ON)
+45 -57
View File
@@ -1,94 +1,79 @@
# Contributing guidelines
# Contributing to OpenMoHAA VR
Whether you're reporting issues or coding, follow the guidelines below to help keep the project consistent and maintainable.
OpenMoHAA VR is an independent VR port of [OpenMoHAA](https://github.com/openmoh/openmohaa), with standalone Meta Quest releases and Steam Frame as a development target. There is no Steam Frame binary yet. These guidelines apply to [bearstonem/openmohaa-vr](https://github.com/bearstonem/openmohaa-vr); upstream maintains its own contribution policies and release process.
Start with the [port status](VR_PORT_STATUS.md), [repository layout](LAYOUT.md), and [renderer handoff](VR_RENDERER_HANDOFF.md). The status document separates device-tested behavior from changes that still need headset validation.
All contributions, including **issues**, **discussions**, **code comments**, **commit messages**, and **documentation**, must be written in *English*. This ensures clarity and consistency for all contributors.
There are many ways to contribute. Contributions are welcome in the following areas:
- Fixing [bugs and crashes](#issues).
- Documentation improvements
- Performance improvements
- Testing
- Helping users (issues, discussions)
- Suggesting enhancements (via issues only)
- VR rendering, tracking, controller interaction, and HUD improvements.
- Performance improvements backed by measurements.
- Headset testing and reproducible regression reports.
- Documentation and build improvements.
- Steam Frame development and testing, with support claims kept explicit.
- Helping users and proposing enhancements through this repository's issues.
New features are designed and implemented by maintainers, so the project's direction and maintenance burden stay consistent.
If you want to propose a new feature, please open an issue first.
Do not start implementing a new feature before getting approval. Pull requests that introduce unapproved features may be closed without review.
Discuss major features or architecture changes in a [port issue](https://github.com/bearstonem/openmohaa-vr/issues) before investing in a large implementation. Focused fixes, tests, and documentation improvements can be submitted directly.
---
## Issues
Before opening a new issue:
1. Make sure you're running the [latest development version](https://github.com/openmoh/openmohaa/releases/tag/dev).
2. Check if the issue already exists.
When filing an issue, include as much detail as possible, this helps reproducing and fixing the problem effectively:
- Clear description of the bug.
- Steps to reproduce.
- Log file (`qconsole.log`) and crash output if applicable:
- Attach the relevant log file.
- For crashes, paste the console output that includes the backtrace.
- Operating System and version. Example: `Debian 13`.
- Full game version. Example: `0.70.0-alpha+0.0b1a20dcf win_msvc64-x86_64-debug`. The version is obtained at the start of the log file, or by executing the `version` command in console.
1. Check the [port releases](https://github.com/bearstonem/openmohaa-vr/releases) and [known issues](VR_PORT_STATUS.md). Upstream desktop releases do not include this port's VR changes.
2. Search [existing port issues](https://github.com/bearstonem/openmohaa-vr/issues).
## Usage of generative AI / Large language models
Include the following details to help reproduce and diagnose an issue:
### Policy
- A clear description and reproducible steps, including the mission/map and game edition.
- Headset model, headset OS/firmware, controllers, and OpenXR runtime/version when known.
- Port release and asset name, or the source commit and build configuration. Include the engine version from the startup log or `version` command as well.
- Relevant VR/render settings, mods, and whether the release's external `autoexec.cfg` was installed.
- Startup and failure logs, including `qconsole.log`, Android logcat, or runtime logs as appropriate. Include a backtrace for crashes.
- For visual or performance issues, whether one or both eyes are affected, and any captures or frame-time measurements.
This project requires that all contributions be human-authored.
For Quest logs, begin capture before reproducing the issue: `adb logcat -v threadtime -s openmohaa:V AndroidRuntime:E > openmohaa-vr.log`. See the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md) for the full checklist.
Do not submit any code, documentation, issues, pull requests, commit messages or comments that are generated using AI or similar assistive tools (such as Microsoft Copilot, Anthropic Claude, ChatGPT...).
Report port problems here first. If a problem also reproduces in upstream OpenMoHAA, provide that evidence and follow upstream's policies when reporting or contributing there.
- **No AI-derived patches**. If the change was proposed by an AI, it must not be ported over "by hand". If the idea came from AI, treat is as untrusted and do your own independent implementation.
- **No AI-assisted refactors**. Mass rewrites, LLM-based formatting, or modernization are not accepted.
- **Enforcement**. Maintainers may request clarification. PRs suspected of AI involvement may be rejected or reverted, and may lead to a ban.
## Tools and contribution responsibility
#### Acceptable uses
AI-assisted contributions are welcome in this fork. Contributors remain responsible for reviewing, understanding, and validating every submitted change, including generated code and documentation. Explain the problem, the resulting behavior, and the checks performed; distinguish source review, automated tests, and actual headset testing.
- **Non-generative tools** are allowed: static analyzers, formatters, linters, sanitizers, fuzzers and IDE features are fine as long they do not generate code.
- **Translation tools** to help communicating between people.
Keep changes focused and reviewable. Preserve license headers, copyright notices, and authorship attribution, and identify the source and license of imported code or assets. Do not submit proprietary game data. Tool use does not change these responsibilities.
### Reasoning
This policy exists due to:
- **Copyright and licensing risk**. The origin of the outputs may be unclear as these tools plagiarize the original sources without permission.
- **Security and authenticity**. AI produces plausible code but may often look wrong. This could work, but would turn into undefined behavior, vulnerabilities, or time spent in a debugger.
- **Maintability**. Changes must be explainable and reviewable. Humans can explain tradeoffs they consciously accepted. Generated code can't. If you can't explain why a specific line exists, it shouldn't be proposed at all.
- **Respect for maintainers**. Submitting contributions generated by these tools waste maintainers resources to review them.
### Responsibility
When submitting, you agree that:
- The submission **complies with this policy**. Changes must be able to be explained and justified during review.
- The submission **can be declined** if it is **suspected to use AI** or is raising reviewability concerns.
This policy applies only to OpenMoHAA VR. It does not override upstream OpenMoHAA's independently maintained policies or authorize sending AI-assisted work upstream.
## Coding
### Compatibility
All changes must stay retro-compatible with the original game:
Preserve the engine's compatibility with the original game while adapting presentation and input for VR:
- Assets must be loaded correctly.
- Changes related to networking must be compatible with existing MOHAA clients and servers, these changes must not cause an error/disconnect.
- Scripts/mods must remain functional.
- The singleplayer campaign must be fully playable.
- Preserve single-player campaign behavior; report which missions and expansions were actually tested.
- Keep desktop and dedicated-server builds working when changing shared code.
- Do not change existing package IDs, config/save paths, network identifiers, or binary/module names solely for display branding.
### Coding style & Guidelines
- Match the existing code style in any files you modify.
- Avoid personal tags or author comments (.e.g., `// @Name: note`). Git already tracks authorship.
- Ensure the code builds successfully using CI.
- Use **camelCase** notation for all variables, and **PascalCase** notation for functions. Even if some existing code doesn't.
- Format code using `clang-format`.
- Avoid personal tags or author comments (e.g., `// @Name: note`). Git already tracks authorship.
- Build the affected targets and run relevant checks. State when a target or headset was unavailable.
- Follow the surrounding naming conventions, including engine subsystem prefixes such as `CG_`, `CL_`, `R_`, and `VR_`.
- Format changed code using the repository's `clang-format` configuration; avoid unrelated whole-file formatting.
- In VSCode from the command palette: **Format document**
### Event declaration
When declaring a new Event, use the following structure:
```cpp
Event EV_YourEventName // Pascal Case naming convention
(
@@ -133,6 +118,7 @@ When modifying existing code from the original game or adding new classes, annot
```
If referencing a specific game version, replace OPM with that version number. Example:
```cpp
// Added in 2.11
// Make sure to clean turret stuff up
@@ -141,6 +127,7 @@ If referencing a specific game version, replace OPM with that version number. Ex
Known versions are `1.00`, `1.10`, `1.11`, `2.0`, `2.1`, `2.11`, `2.15`, `2.30`, `2.40`.
You can also group multiple related functions like this:
```cpp
// Added in OPM
//====
@@ -166,26 +153,27 @@ Pull Requests are the preferred way of submitting code changes.
### Before you start
1. **Discuss major changes first**: open an issue or discuss about it.
2. **Create a branch** based on the `main` branch (you shouldn't commit directly on `main`)
1. **Discuss major changes first** in this port's issue tracker.
2. **Create a branch** based on this repository's current default branch, rather than upstream's `main`.
3. **Keep PRs as small as possible and focused**: One clear purpose per PR.
### Submitting a PR
- Target the correct branch (usually `main`).
- Target this repository's current default branch, unless a maintainer requests a different integration branch.
- Add a short and clear description of what you changed and why.
- Reference matching issues (e.g. `Fixes #123`).
- Make sure your code builds and passes tests.
- Test locally to ensure your changes don't break functionality or compatibility.
- For VR changes, list the headset/runtime, build, and scenarios tested. A successful build alone does not establish device support or a performance improvement.
### Reviews
- Maintainers may ask for tweaks or commit changes directly to your PR, that's normal.
- Stay polite and technical.
- Once approved, your changes will be merged.
- Maintainers decide whether and when a change is ready to merge.
### Additional Notes
- Contributions follow the same license as the project.
- Keep things consistent with existing code and docs.
- If you're unsure about anything, you can discuss it on Discord via [this link](https://discord.gg/NYtH58R).
- Use the [OpenMoHAA VR issue tracker](https://github.com/bearstonem/openmohaa-vr/issues) for port questions and proposals.
+36 -23
View File
@@ -1,24 +1,37 @@
# File layout
# OpenMoHAA VR repository layout
| Path | Description |
| ---------------------|-------------------------------------------------------------------
| code/ | OpenMoHAA source code |
| code/cgame | Client game code |
| code/client | Client (network stuff, snapshots, etc) |
| code/game | Server game code (game logic stuff, etc) |
| code/gamespy | Gamespy SDK |
| code/null | Null redirector (used by the OpenMoHAA Dedicated Server) |
| code/parser | Flex/Bison parser |
| code/qcommon | Global shared stuff such as maths |
| code/renderer | OpenMoHAA renderer |
| code/sdl(12) | SDL code |
| code/script | Script engine |
| code/server | Server code (network, snapshots, handling clients etc) |
| code/skeletor | Skeleton model engine |
| code/sys | Main application system |
| code/tiki | Complete TIKI engine |
| code/uilib | UI library (Ubertools stuff) |
| code/{win32,unix} | Platform-specific code |
| docker | Docker files, for compiling on various linux versions |
| docs | Documentation stuff |
| misc/ | Project stuff, definition files and various resources stuff. |
This fork contains the OpenMoHAA engine and its VR port. Meta Quest is the current standalone release target; Steam Frame is a development target with no binary available yet. Existing engine names, package IDs, and data paths remain in place for compatibility.
Start with [VR_PORT_STATUS.md](VR_PORT_STATUS.md) for current support and known issues. The older plan and renderer handoff record design decisions and historical measurements; they are not release support claims.
| Path | Description |
| --- | --- |
| [VR_PORT_STATUS.md](VR_PORT_STATUS.md) | Port status, tested behavior, known issues, and build/deployment notes. |
| [VR_PORT_PLAN.md](VR_PORT_PLAN.md) | Original Quest port survey and implementation plan. |
| [VR_RENDERER_HANDOFF.md](VR_RENDERER_HANDOFF.md) | Renderer migration and gl4es integration history. |
| [QUEST_PORTING_GUIDE.md](QUEST_PORTING_GUIDE.md) | Lessons and reference workflows from the Quest port. |
| `code/vr/` | OpenXR session, frame submission, tracking, and controller input; `vr_common.h` exposes the engine-facing API. |
| `code/cgame/` | Client game presentation, view models, and the grouped VR HUD in `cg_vrhud.cpp`. |
| `code/client/` | Client networking, snapshots, UI integration, and VR frame routing. |
| `code/fgame/` | Server game logic, including gameplay interactions used by the VR port. |
| `code/renderergl1/` | Fixed-function renderer and VR view composition; the default Android renderer uses gl4es. |
| `code/renderergl2/` | Shader-based renderer and its VR integration. |
| `code/renderercommon/` | Shared renderer interfaces, image handling, and related utilities. |
| `code/qcommon/` | Shared engine code, math, filesystem, and configuration handling. |
| `code/corepp/` | Shared C++ infrastructure. |
| `code/server/` | Server networking, snapshots, and client handling. |
| `code/null/` | Platform stubs used by dedicated-server builds. |
| `code/sdl/` | SDL input and platform integration. |
| `code/sys/` | Platform system code, including Android support under `code/sys/new/`. |
| `code/uilib/` | Widget and UI library. |
| `code/script/`, `code/parser/` | Script engine and Flex/Bison parser. |
| `code/skeletor/`, `code/tiki/` | Skeletal models, animation, and the TIKI model system. |
| `code/botlib/`, `code/gamespy/` | Bot support and inherited GameSpy SDK integration. |
| `code/thirdparty/` | Bundled third-party dependencies and their notices. |
| `misc/android/` | Gradle project, Android manifest/resources, and the external `autoexec.cfg` supplied with Quest releases. |
| `misc/linux/`, `misc/macos/`, `misc/windows/` | Desktop packaging and platform resources. |
| `cmake/`, `CMakeLists.txt` | Native build configuration and dependency integration. |
| `.github/` | Build/release workflows and issue templates for this repository. |
| `docs/` | Engine documentation and Android/VR integration notes. |
Use [CONTRIBUTING.md](CONTRIBUTING.md) for development conventions and report port issues in [bearstonem/openmohaa-vr](https://github.com/bearstonem/openmohaa-vr/issues).
File diff suppressed because it is too large. Load diff
+62 -78
View File
@@ -1,109 +1,93 @@
# OpenMoHAA
# OpenMoHAA VR
[![Build](https://github.com/openmoh/openmohaa/actions/workflows/branches-build.yml/badge.svg?branch=main)](https://github.com/openmoh/openmohaa/actions/workflows/branches-build.yml) [![Release](https://img.shields.io/github/v/release/openmoh/openmohaa)](https://github.com/openmoh/openmohaa/releases) [![Downloads](https://img.shields.io/github/downloads/openmoh/openmohaa/total)](https://github.com/openmoh/openmohaa/releases)
**A standalone VR port of Medal of Honor: Allied Assault targeting Meta Quest and Steam Frame.**
![License](https://img.shields.io/github/license/openmoh/openmohaa) ![Commits](https://img.shields.io/github/commit-activity/t/openmoh/openmohaa)
[![Quest prerelease](https://img.shields.io/github/v/release/bearstonem/openmohaa-vr?include_prereleases&label=Quest%20prerelease)](https://github.com/bearstonem/openmohaa-vr/releases)
[![Downloads](https://img.shields.io/github/downloads/bearstonem/openmohaa-vr/total)](https://github.com/bearstonem/openmohaa-vr/releases)
[![License](https://img.shields.io/badge/license-GPL--2.0--or--later-blue)](COPYING.txt)
![Discord](https://img.shields.io/discord/596049712579215361?logo=discord&logoColor=white&color=5865F2)
OpenMoHAA VR brings the original game into room-scale VR: head tracking, controller-aimed weapons, tracked hands, physical interactions, and menus and HUD adapted for a headset. The game runs on the headset itself. A PC is needed for the documented sideloading workflow, not for streaming gameplay.
![logo](misc/openmohaa-text-sm.png)
This is the VR development fork of [OpenMoHAA](https://github.com/openmoh/openmohaa). You must supply your own original game data; no Medal of Honor game assets are included.
## What is OpenMoHAA?
## Platforms and current status
OpenMoHAA is an open-source project aimed at preserving and enhancing **Medal of Honor: Allied Assault** (including Spearhead and Breakthrough expansions) by providing more features and bugfixes, across modern platforms and architectures.
| Target | Status |
| --- | --- |
| **Meta Quest** | Standalone Android/OpenXR prerelease available. Quest 3 is the headset tested so far; other Quest models are unverified. |
| **Steam Frame** | Standalone VR development target. Platform integration and device validation are still pending; there is no Steam Frame release yet. |
Powered by [ioquake3](https://github.com/ioquake/ioq3) and the [F.A.K.K SDK](https://code.idtech.space/ritual/fakk2-sdk), OpenMoHAA provides:
- Full compatibility with the original game: assets, scripts and multiplayer
- Better support for modern systems
- Cross-platform support (Linux, Windows, macOS)
- Support for both single-player and multiplayer modes
- Includes all fixes from Spearhead 2.15 and Breakthrough 2.40b
- More fixes and features, such as bots and a ban system
The current VR backend uses Android, EGL, and OpenGL ES. The inherited desktop engine build is useful for development, but it does not establish Steam Frame VR support.
*OpenMoHAA is an independent project and is not affiliated with or endorsed by Electronic Arts.*
**This is a port in progress.** The latest code review passed desktop and Android builds and targeted regression checks. Its visual changes and performance impact have not yet been measured in a headset. Upstream campaign, expansion, mod, and multiplayer compatibility should not be read as a claim that each has been validated in this VR port.
## Getting started
## Download and install on Quest
- 📦 [Installing OpenMoHAA](docs/markdown/01-intro/01-installation.md)
- ▶️ [How to play: Launching the game, expansions & file locations](docs/markdown/02-running/01-running.md)
- ❓ [FAQ & Troubleshooting](docs/markdown/02-running/03-faq.md)
- 🌐 [Setting up a game server](docs/markdown/02-running/02-running-server.md)
Get the APK, `autoexec.cfg`, and `SHA256SUMS` from the [VR releases page](https://github.com/bearstonem/openmohaa-vr/releases). The current release is [0.2.0](https://github.com/bearstonem/openmohaa-vr/releases/tag/vr-quest3-0.2.0).
## Reporting Issues
1. Install the APK:
> [!NOTE]
> OpenMoHAA hasn't hit version 1.0.0 yet. Think of it like a beta build from the golden age of LAN parties. Features are being added, bugs are getting squashed, and more things are being tweaked. Things might change, break, or get even better over time.
>
> If that sounds like your kind of mission, gear up, frag some bots, and help level up OpenMoHAA!
```sh
adb install -r openmohaa-vr-quest3-0.2.0.apk
adb shell am force-stop org.openmoh.openmohaa
```
If you encounter a bug or a problem, you can do one of the following:
- Submit an [issue](https://github.com/openmoh/openmohaa/issues) on GitHub (use the template).
- Join the [OpenMoHAA Discord](https://discord.gg/NYtH58R) for a quick help.
2. On a fresh installation, launch once from inside the headset to initialize the app's files directory, then close it. Supply the retail game's `main/` data under `/sdcard/Android/data/org.openmoh.openmohaa/files/main/`. See the [Quest build and installation guide](docs/markdown/04-coding/03-android.md) for the data-copy steps.
3. Copy the release configuration:
## Additional documentation
```sh
adb push autoexec.cfg /sdcard/Android/data/org.openmoh.openmohaa/files/main/autoexec.cfg
adb shell am force-stop org.openmoh.openmohaa
```
- 📖 [Documentation](https://openmoh.github.io/openmohaa)
- ⚙️ [Game settings & configuration](docs/markdown/03-configuration/01-configuration.md)
- 📝 [Code & Scripting reference](docs/markdown/04-coding/02-coding.md)
- 📜 [Contributing guidelines](CONTRIBUTING.md)
4. Launch the game from inside the headset, under Unknown Sources. Release 0.2.0 appears as **OpenMoHAA**; subsequent source builds use **OpenMoHAA VR**. Do not start it with `adb shell am start`.
## Current state
**When upgrading, update the configuration too.** Reinstalling the APK keeps the old `autoexec.cfg`; back up personal changes before replacing it. The supplied configuration starts at the normal menu, disables the development cheats and test map, and includes the current lighting workaround. Let the app create its own `main/save` and `main/configs` directories.
- 🧰 [List of differences](docs/markdown/01-intro/04-differences.md)
The APK is debug-signed with optimized Release native libraries. Version 0.2.0 uses the same signing certificate as 0.1.0 for upgrades. Verify downloaded assets with `sha256sum -c SHA256SUMS`.
### Single-player
## VR features
The entire single-player campaign should work (Allied Assault, Spearhead and Breakthrough). If you encounter any bug, please create a new [GitHub issue](https://github.com/openmoh/openmohaa/issues) describing them.
- Stereo OpenXR rendering and 6DoF head tracking.
- Controller-aimed weapons, tracked hands, stick locomotion, and snap turning.
- Body holsters, grip-driven ladder climbing, and physical grenade throws.
- Menus and mission/death screens displayed on a panel in the room.
- An on-demand HUD on the off-hand **X** button: compass, objectives, health, and ammo.
- Controller-based use interaction and a weapon-selection wheel.
### Multiplayer
These systems are implemented; controller interactions, weapon alignment, and individual missions still need broader device testing.
- Almost fully stable
- All official game modes are supported, including those from Spearhead and Breakthrough:
- Free-For-All
- Team-Deathmatch
- Round-based match
- Objective match
- Tug-of-War (Spearhead)
- Liberation (Breakthrough)
- Popular mods like **Freeze-Tag** are supported
- Built-in bots for offline practice and for testing
- 🔧 [Setting up bots](docs/markdown/02-running/01-running.md#Playing-with-bots)
## Known issues
You can host your own [OpenMoHAA server](docs/markdown/02-running/02-running-server.md#) or join others using OpenMoHAA.
- Some fonts can render as black boxes because of an unresolved font-atlas texture issue.
- Dynamic lights can darken the scenery. The supplied `autoexec.cfg` keeps `r_worldDlights 0`; the APK's defaults alone do not apply that workaround.
- Per-weapon grip alignment and remaining foliage behavior need further work.
- Fresh headset performance measurements are pending. Earlier Quest 3 measurements are historical, not benchmarks of the latest release.
- Steam Frame integration and validation remain to be done.
## Screenshots
See [port status](VR_PORT_STATUS.md) for implementation details and the historical Quest measurements.
| | |
|-----------------------------------------------------------------------------------|----------------------------------------------------------------------------|
| ![](docs/assets/images/v0.60.0-x86_64/mohdm1_1.png) | ![](docs/assets/images/v0.60.0-x86_64/training_1.png) |
| ![](docs/assets/images/v0.60.0-x86_64/flughafen_1.png) | ![](docs/assets/images/v0.60.0-x86_64/flughafen_2.png) |
| ![](docs/assets/images/v0.60.0-x86_64/mohdm2_1.png "Playing Freeze-Tag mode with bots") | ![](docs/assets/images/v0.60.0-x86_64/training_3.png "Single-Player training") |
## Development
*More screenshots [here](docs/assets/images)*
The active development branch is `vr-quest3`; the project targets both Quest and Steam Frame.
## Development & Compiling
```sh
git clone --branch vr-quest3 https://github.com/bearstonem/openmohaa-vr.git
cd openmohaa-vr
```
- 💻 [Building from source](docs/markdown/04-coding/01-compiling.md)
- [Quest / Android build and installation](docs/markdown/04-coding/03-android.md)
- [Documentation index](docs/README.md)
- [Source layout](LAYOUT.md)
- [Contributing](CONTRIBUTING.md)
- [Port status](VR_PORT_STATUS.md), [original Quest plan](VR_PORT_PLAN.md), and [renderer notes](VR_RENDERER_HANDOFF.md)
- [Reusable Quest porting guide](QUEST_PORTING_GUIDE.md)
- [Desktop engine build reference](docs/markdown/04-coding/01-compiling.md)
## Third party librairies
## Feedback and credits
The following third party tools and libraries are used by the project
Report port bugs in [this repository's issue tracker](https://github.com/bearstonem/openmohaa-vr/issues). Include your headset, release or commit, map, steps to reproduce, and relevant logs. Steam Frame development reports should include the runtime and build configuration.
- [Flex](https://github.com/westes/flex)
- [Bison](https://savannah.gnu.org/projects/bison/)
- [SDL](http://www.libsdl.org/)
- [OpenAL](https://www.openal.org/)
- [LibMAD](http://www.underbit.com/products/mad/)
- [cURL](https://curl.se/)
- [Libogg](https://github.com/gcp/libogg)
- [Libvorbis](https://xiph.org/vorbis/)
- [Libopus](https://opus-codec.org/)
Built on the work of the [OpenMoHAA project](https://github.com/openmoh/openmohaa), [ioquake3](https://github.com/ioquake/ioq3), and the F.A.K.K SDK. [Team Beef's RTCWQuest](https://github.com/DrBeef/RTCWQuest) is the reference for much of the VR integration. The original engine documentation, assets, and attribution remain in the tree.
## Resources
- 🔗 [GitHub Repository](https://github.com/openmoh/openmohaa/)
- 🌐 [MOH-DB](https://www.moh-db.com/)
- 🕹️ [333networks](https://333networks.com/)
- 📂 [ModDB](https://www.moddb.com/games/medal-of-honor-allied-assault)
- 📂 [GameBanana](https://gamebanana.com/games/720)
- 💬 [Join us on Discord](https://discord.gg/NYtH58R)
Engine source is licensed under [GPL-2.0-or-later](COPYING.txt). Medal of Honor game data is separately owned and is not distributed here. This is an independent community port, not an official Electronic Arts, Meta, or Valve release.
+249
View File
@@ -0,0 +1,249 @@
# OpenMoHAA on Quest 3 — porting plan
Getting Medal of Honor: Allied Assault running natively on a Meta Quest 3 in
VR, by way of an Android port first.
Written after surveying the tree at `a2f34019`, and carrying the lessons from
a completed Quest 3 VR port of Homeworld (1999) — the mistakes noted at the
end are ones already made once and worth not repeating.
---
## 1. What we are starting from
| | |
|---|---|
| Lineage | ioquake3-derived (`renderergl1`/`renderergl2`, `qcommon`, `botlib`, QVM options) |
| Build | CMake ≥ 3.25, modular per-directory `CMakeLists.txt` |
| Platform layer | **SDL2** (`code/sdl/`: glimp, input, sound, gamma, mouse) |
| Renderers | `renderergl1` (fixed function), `renderergl2` (GLSL) |
| Game logic | C++ (`fgame`, `cgame`, `uilib`, `skeletor`, `tiki`) — not Q3-style QVM gameplay |
| Android support | **none whatsoever** |
| Game data | Retail MOH:AA required (GOG recommended); a demo exists |
### The finding that shapes everything
**`renderergl2` already speaks OpenGL ES.** This is inherited from upstream
ioquake3 and is in far better shape than expected:
- `qglesMajorVersion` / `qglesMinorVersion` are real runtime variables
(`code/renderercommon/qgl.h`), with a `QGLES_VERSION_ATLEAST` macro
- `tr_glsl.c` emits `#version 300 es` when ES 3 is present, `#version 100`
for ES 2, and injects `precision mediump float` / `precision mediump
sampler2DShadow`
- `tr_extensions.c` branches on `qglesMajorVersion` when probing extensions
- `sdl_glimp.c` has an **`r_preferOpenGLES` cvar** and will request
`SDL_GL_CONTEXT_PROFILE_ES` contexts
This is the single biggest difference from the Homeworld port, where the
engine only spoke fixed-function GL 1.x and needed **gl4es** (a GL-over-GLES2
translation layer) underneath everything — which then dictated every later
constraint. Here the plan is to drive `renderergl2` at GLES 3 directly, so:
- no translation layer, no gl4es quirks
- real shaders, so normal mapping and post-processing stay on the table
- FBOs are already first class (`tr_fbo.c`), which matters enormously for
stereo rendering — Homeworld's port never got this and it blocked MSAA
**`renderergl1` should be considered out of scope.** It is fixed-function and
would need the same emulation layer we are avoiding. Build only `renderergl2`
(`-DBUILD_RENDERER_GL1=OFF -DBUILD_RENDERER_GL2=ON`).
---
## 2. Phase 1 — build for Android
Goal: an APK that launches, logs, and reaches the main menu. No VR yet.
1. **Toolchain.** CMake has first-class NDK support via
`android.toolchain.cmake`; unlike Homeworld's hand-written meson cross
file, this is a supported path. Target `arm64-v8a`, `android-29` or later
(Quest 3 is Android 12).
2. **SDL2 for Android.** Build SDL2 for `arm64-v8a` and use the stock
`org.libsdl.app.SDLActivity` to load the engine as `libmain.so`. The
existing `code/sdl/*` should mostly work unchanged — this is the same
arrangement the Homeworld port used successfully.
3. **Prune the dependency surface.** Decide early, since each is a
cross-compile:
- `USE_OPENAL` — Quest wants low-latency audio; OpenAL Soft builds for
Android, but SDL audio may be simpler for a first pass
- `USE_CURL` / `USE_HTTP` — off initially, it is only for downloads
- `USE_CODEC_VORBIS` / `OPUS` / `MAD` — MOH:AA music needs at least one;
check what the retail data actually ships
- `BUILD_SERVER` — off for the client APK
4. **Game modules.** `BUILD_GAME_LIBRARIES` vs `BUILD_GAME_QVMS`. Native
`.so` modules via `dlopen` work on Android, but Android's linker
namespaces are fussy about paths — consider **static linking the game
modules into the main binary** for the first port and revisiting later.
5. **Filesystem.** Point the base path at the app's external files directory
(`SDL_AndroidGetExternalStoragePath()`), and `chdir()` there early —
exactly what the Homeworld port does. The user side-loads `main/pak*.pk3`
into it.
6. **Force the ES path.** Set `r_preferOpenGLES 1` and confirm
`qglesMajorVersion` comes back as 3. This is the moment of truth for the
whole approach and should be validated before anything else is built on
top.
**Exit criteria:** APK boots, logs to `logcat`, reaches the main menu with
retail data side-loaded, renders through `renderergl2` on a GLES 3 context.
---
## 3. Phase 2 — playable flat on the headset
Goal: the game is genuinely playable as a 2D panel app before VR is attempted.
This is deliberately a separate milestone; debugging engine problems is far
easier without a stereo compositor in the way.
- **Input:** Quest Touch controllers appear to SDL as a game controller.
Map to the existing `sdl_input.c` joystick path first; a bespoke mapping
comes later with VR.
- **Performance baseline:** measure frame time with the flat renderer before
stereo triples the cost. Establish what the Quest 3 GPU actually delivers on
a large MOH:AA map — this number decides how ambitious VR can be.
- **Config and saves:** confirm they write to the external files dir, and that
the directory is app-owned (see the ownership trap in §6).
- **Audio:** verify no dropouts; Android audio callbacks are less forgiving
than desktop.
**Exit criteria:** a mission is completable, flat, on the headset, at a stable
frame rate.
---
## 4. Phase 3 — stereo via OpenXR
Goal: the world renders in tracked stereo at 72 fps.
1. **Session and swapchains.** Link the Khronos `openxr_loader` for Android,
create an instance with `XR_KHR_opengl_es_enable`, and drive
`xrWaitFrame` / `xrBeginFrame` / `xrEndFrame` from the engine's frame loop.
2. **Two eye passes into FBOs.** `renderergl2` already renders into
framebuffer objects (`tr_fbo.c`), so each eye can target an OpenXR
swapchain image directly. **This is the piece the Homeworld port never
built**, and its absence there blocked MSAA and forced awkward blits — do
it properly from the start here.
3. **View and projection from OpenXR.** Replace the engine's projection setup
with the per-eye FOV and pose from `xrLocateViews`. In id Tech the camera
comes from `refdef` — feed eye offsets in there rather than hacking the
projection matrix downstream.
4. **Do not render the world three times.** The Homeworld port ended up with a
mono pass plus two eye passes because the 2D UI and camera capture were
entangled with the main view. Design the frame as *exactly two* eye passes
from day one; anything needing a flat image should read from an eye buffer
or render separately.
5. **The 2D UI.** MOH:AA's HUD and menus draw in screen space. Two options,
probably both eventually: render them to a texture and present as an
OpenXR quad layer (crisp, comfortable, good for menus), or project them
into the world (better for the in-game HUD).
**Exit criteria:** stereo world, correct scale and IPD, head tracking with no
mismatch, stable 72 fps, menus readable.
---
## 5. Phase 4 — VR interaction design
This is the largest and least mechanical phase, and it is where an FPS differs
completely from the RTS port. A wrong choice here causes motion sickness
rather than a bug report.
**Locomotion — the central question.**
- Smooth locomotion with comfort options (vignette, snap turn) is the
expected default for a shooter, but MOH:AA's level design assumes a fast,
free-running player
- Teleport is comfortable but destroys the pacing of firefights
- Likely: smooth movement, snap *and* smooth turn as options, adjustable
vignette. Make all of it a cvar so it can be tuned on device.
**Weapon handling.**
- Aim from the controller, not the head — this is non-negotiable for an FPS
- Two-handed hold for rifles is a large fidelity win and a large amount of
work; consider one-handed first
- Iron sights become genuinely physical: the sight has to line up with the eye
- Reloading: gesture vs button. Button first.
**The player body and camera.**
- MOH:AA moves the view for scripted sequences, cutscenes, ladder climbs and
death animations. **Any engine-driven camera motion is a nausea risk in
VR** and every instance needs auditing. This is likely the single largest
source of unpleasant surprises.
- Player height: real standing height vs the engine's fixed viewheight
- Crouch: physical vs button, ideally both
**HUD.**
- Screen-locked HUD elements are uncomfortable in VR
- Ammo and health on the weapon or wrist, as the Homeworld port did with
wrist cards — that worked well and is worth reusing
- Objectives and subtitles on a comfortable head-relative panel
**Exit criteria:** a mission playable end to end, comfortably, by someone who
is not the developer.
---
## 6. Traps carried from the Homeworld Quest port
Hard-won, all of them cost hours:
- **Never `adb shell am start` to launch after deploying.** It intermittently
strands the headset in Meta's loading environment. Install, then launch from
inside the headset.
- **`adb push` cannot create directories inside an app's data folder**, and
`chmod` must come *after* pushing or adb fails with `remote fchown failed`.
- **A directory created by `adb shell mkdir` is mode 2770 owned by `shell`**,
which the app cannot even traverse into — it fails *silently*. `chmod 775`
afterwards. Files pushed by adb are 644 and world-readable, which is why
data files work while directories do not.
- **Verify your logging works before relying on it.** A crash handler that has
been silently displaced reports nothing, and is indistinguishable from a
crash that never happened. Add a startup self-test that exercises the same
path.
- **`tombstoned` may not write backtraces to logcat.** When it does not,
`dumpsys activity exit-info` still reports the signal, and an in-process
handler using `_Unwind_Backtrace` + `dladdr` can report the stack itself —
but it needs a real `sigaltstack`, or a stack overflow silences it.
- **Only one process may hold an OpenXR session.** If the previous run has not
fully exited, the new one falls back to flat mono rendering with no error.
- **The headset must be awake** or the session never reaches
`XR_SESSION_STATE_FOCUSED` and the launch appears to hang.
- **Wireless adb ports change** every time debugging is toggled, and mDNS can
advertise a dead one.
---
## 7. Open questions to settle early
1. **Does `renderergl2` actually initialise on a Quest GLES 3 context?**
Everything above rests on this. Validate in Phase 1 before building more.
2. **What frame budget does a real MOH:AA map leave?** id Tech 3 maps are
light by modern standards, but MOH:AA's are large and the Quest 3 must
render everything twice at 72 fps.
3. **Game modules: static or `dlopen`?** Decide before the build is shaped
around either.
4. **How much engine-driven camera movement is there?** Grep for view
overrides early; it drives the Phase 4 comfort work and may be the deciding
factor in how pleasant this is to play.
5. **Which MOH:AA data edition to target first?** GOG is recommended by the
project and is the easiest for testers to obtain legally.
6. **Licensing.** OpenMoHAA is GPL; no game assets may be redistributed. Same
posture as the Homeworld port: the user supplies their own `pak` files.
---
## 8. Suggested order of work
```
1 Android build of the dedicated server or a headless client (proves the toolchain)
2 Full client APK, GLES 3 context, main menu (proves the renderer)
3 Side-load retail data, reach gameplay, flat (proves the filesystem)
4 Controller input, playable flat (baseline + perf numbers)
5 OpenXR session, one eye, correct pose (proves the XR path)
6 Two eye FBOs, correct IPD and projection (stereo)
7 UI as quad layers (menus usable)
8 Locomotion and weapon handling (VR design begins)
9 Comfort pass, camera audit, HUD (the long tail)
```
Steps 1–3 are mechanical and mostly build engineering. Step 5 onward is where
the interesting decisions live.
+419
View File
@@ -0,0 +1,419 @@
# OpenMoHAA VR — platform status
This repository develops a standalone Medal of Honor: Allied Assault VR port
for **Meta Quest and Steam Frame**. The released build is currently the Android
OpenXR port, tested on Quest 3. Other Quest models are unverified. Steam Frame
is a development target: platform integration and device validation are pending,
and no Steam Frame binary is available yet.
The notes below document the existing Quest implementation and historical device
measurements. They do not establish support or performance on Steam Frame.
Companion to `VR_PORT_PLAN.md`, which is the original survey and plan, and to
`VR_RENDERER_HANDOFF.md`, which covers the move to the fixed function renderer.
This records what has actually been built, what was learned that contradicts the
plan, and what to do next.
Branch `vr-quest3`. The historical status below records previously committed work.
### Quest release 0.2.0 — October 14, 2026
The Android package uses version code 2 and version name `0.83.0-vr0.2.0`.
The supplied `autoexec.cfg` starts at the normal menu, with cheats, automatic
weapon grants, test-map loading, and diagnostic traces disabled. Replace the
external config when upgrading: reinstalling the APK preserves the old one.
Keep `r_worldDlights 0` until the lighting defect is resolved; that workaround
is supplied by the config, not by the APK's defaults. This remains a prerelease,
and the September review changes still need headset validation.
### Code review update — September 29, 2026
These fixes remove the unconditional GPU wait after each eye;
`vr_traceGpu 1` restores that wait only for diagnostics. Normal submission flushes
commands and discards the eye's transient depth/stencil buffers. Swapchain timeouts
now retain the acquired image for retry, and stereo submission requires both eyes.
World/HUD timing now charges the first 2D transition's world flush to the world,
and backend timing accumulates all command batches in an eye.
The review also fixes desktop HUD suppression, objective initialization, HUD
resource resets, health colors, weapon-wheel placement, stereo text lifetimes,
font shader passes, and sprite culling and upright sprite disappearance when
looking vertically. CMake now tracks additions to the client-game source list.
No headset was connected for this review: the measured frame rates and visual
observations below predate these changes. Device validation is still needed.
The existing font-atlas texture issue and dynamic-light darkening remain
unresolved; `r_worldDlights 0` in `autoexec.cfg` remains the lighting workaround.
**On any problem this port is trying to solve, read RTCWQuest first.** It is
cloned at `/home/berkybear/RTCWQuest`, clean at `4c714b9`, and it is finished.
That is a standing instruction, not a suggestion; ignoring it has cost more time
here than any single bug. It is also not always live code — read what is *called*,
not what is present.
The reusable form of everything below is in `QUEST_PORTING_GUIDE.md`, which is
written for the next port rather than this one.
---
## 1. What works
The game runs on a Meta Quest 3 in stereo VR at **90 fps**: 6DoF head tracking,
stick locomotion, snap turning, menus on a panel fixed in the room, the weapon in
the player's hand aimed by the controller, and a hand on each controller. A
mission can be started and played.
| Phase | State |
|---|---|
| **1 — Android build** | Done. APK builds, boots, logs to logcat. |
| **2 — Playable flat** | Skipped deliberately; went straight to VR at the user's direction. |
| **3 — OpenXR stereo** | Done. 90 fps, 4–6 ms per eye in the back end. |
| **4 — VR interaction** | Locomotion, snap turn, menu pointer, controller aim, weapon in hand, hands on both controllers. HUD and per-weapon alignment outstanding. |
Confirmed on the device, in order of when each stopped being a question:
- the world renders — terrain, BSP, models, textures, lightmaps, fog, sky
- 90 fps at 4–6 ms per eye, against rend2's 50–60 (`VR_RENDERER_HANDOFF.md` §2)
- `XR_FB_display_refresh_rate` works; the display runs at 90 Hz
- colour is correct, and therefore darker than it was — see §2
- the start map loads straight into the world without touching a menu
- both controllers track 1:1, with no drift on head rotation and no lag on a
snap turn
- both hands are anchored on their own bones and sit upright at the right size
### Build
renderergl1 on gl4es is the default on Android; renderergl2 is still reachable
with `-DBUILD_RENDERER_GL1=OFF -DBUILD_RENDERER_GL2=ON -DUSE_GL4ES=OFF`.
```sh
cmake -B build-gl1 -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-29 -DANDROID_STL=c++_shared \
-DCMAKE_BUILD_TYPE=Release -DBUILD_SERVER=OFF
cmake --build build-gl1 -j$(nproc)
cd misc/android && gradle assembleDebug -PnativeLibsDir=../../build-gl1/apk-libs
cd ../.. && adb install -r misc/android/app/build/outputs/apk/debug/app-debug.apk
adb push misc/android/autoexec.cfg \
/sdcard/Android/data/org.openmoh.openmohaa/files/main/autoexec.cfg
adb shell am force-stop org.openmoh.openmohaa
```
SDL2, OpenAL Soft, gl4es and the Khronos OpenXR loader are all fetched and built
from source; `-DSDL2_SOURCE_PATH=` and friends point at local trees instead. Full
instructions and the deployment traps are in
`docs/markdown/04-coding/03-android.md`.
**`adb install -r` does not kill the running process**, so force-stop after
installing or the next launch resumes the old build. **Check the APK is newer
than the `.so`** — gradle will report success while packaging the previous
library. **Never `adb shell am start`**; launch from inside the headset.
### Game data
Lives in `/sdcard/Android/data/org.openmoh.openmohaa/files/main/`. Currently
populated from a GOG War Chest install.
**Never create `main/save` or `main/configs` with `adb`.** A directory made by
`adb shell mkdir` belongs to `shell`, and no chmod makes it app-writable — the
app is `other` there. MOH:AA autosaves on mission start and treats the failure
as fatal, so the symptom is a black screen after New Game with
`FS_CreatePath: failed to create path` in the log. Let the engine create them.
### Device configuration
`misc/android/autoexec.cfg`, deployed next to the game data, not in the APK. It
is the only way to set a cvar on a device with no console: exec'd after
`omconfig.cfg`, before `R_Init`, so it beats both the stored config and the
renderer's own defaults. It cannot reach `VR_TuningCvar` cvars, which force their
value at every start.
The test harness in it ships **off**, so the game starts at its own menu. Filling
in `cheats 1`, `vr_testStart "give all"` and `vr_startMap "m4l1"` drops straight
into The Bocage with every weapon, which is how the hands were tuned; the values
and the reasoning for that map are kept in the comments beside them.
Nothing the game needs to be correct is only in this file. It is a tuning and
testing knob — the defaults compiled into the build are the values measured on
device, so an APK installed without it still behaves.
---
## 2. Where the plan was wrong
Worth reading before trusting `VR_PORT_PLAN.md` on these points.
**"Drive `renderergl2` at GLES 3 directly; `renderergl1` is out of scope" — the
opposite happened, and it was the single largest win.** rend2 ran at 10–15 fps
against a 90 Hz display: 50–60 ms of *CPU* per eye in the back end binding GLSL
programs, while the GPU finished in 3 ms. None of what that buys is visible on
art with no normal or specular maps. The port now runs renderergl1 — the plain
Quake 3 forward renderer — on gl4es, at 4–6 ms per eye. The whole of
`VR_RENDERER_HANDOFF.md` is about that swap and the gl4es boundary it dragged in.
**"`renderergl2` already speaks OpenGL ES" — nearly, but it could not start.**
`QGL_1_1_PROCS` carried five entry points that exist in no version of GLES, and
the ES 3.0 context was only requested under `#ifdef __EMSCRIPTEN__`. Both fixed
in `f3ca6230`; both would have broken any GLES target.
**"FBOs are already first class" — they were switched off on GLES**, and are
deliberately left off on Android (`r_ext_framebuffer_object 0`): in VR each eye is
already a swapchain image, so routing through `tr.renderFbo` first costs a second
full-resolution pass and resolve per eye for nothing.
**"SDL audio may be simpler for a first pass" — there is no SDL audio path.**
`snd_local_new.h` hardcodes `#define SOUND_DRIVER OPENAL`, and `USE_OPENAL=OFF`
does not compile. OpenAL Soft is built from source on its OpenSL ES backend.
**"Game modules: static or dlopen?" — static is not an option.** `cgame` and
`fgame` both compile `q_shared`, `q_math` and the script system, so linking them
into one binary collides. They ship as `libgame.so` / `libcgame.so`.
**Quest 3 is Android 14 / API 34**, not 12.
**Do not set the render resolution through cvars.** `r_mode` is `CVAR_LATCH` *and*
registered by the renderer itself, so nothing the client sets beforehand can win.
The renderer asks the client instead, via `refimport_t::GetVRRenderResolution`.
**The world is 52.5 units to the metre, not 32.** MOHAA's art is authored at 16
units per foot — its tik files say so where they convert from centimetres
(`models/weapons/bar.tik`: *"16/30.5 since world is in 16 units per foot"*), and
`MAXS_Z 94` / `DEFAULT_VIEWHEIGHT 82` agree at 1.79 m standing. `vr_worldscale`
was 32 for months, which is the id Tech number for a game built in inches and
simply the wrong engine's. Everything measured in metres was 1.64× too small, and
it presented as *the hand models being too large* rather than as a scale error,
because a correctly sized model drawn too close is the only clue and the world
gives you nothing to compare against.
---
## 3. How the VR layer is put together
`code/vr/vr_openxr.c`, plus `vr_common.h` as the engine-facing API.
**The session binds to SDL's existing EGL context**, made current against a 16×16
pbuffer because SDL unbinds it and reports success when there is no window
surface (`VR_RENDERER_HANDOFF.md` §5.6). One GL context in the process.
**Eyes are rendered by substituting the renderer's idea of the screen.**
`re.SetDefaultFramebuffer` points the renderer at the eye's swapchain image, so
the entire render path lands in the headset without knowing. Exactly two eye
passes per frame; deliberately no third mono pass.
**The session is torn down and rebuilt around renderer restarts**, and
window-resize restarts are declined outright under VR — one of those was
destroying the context five seconds into every run and was the black menu.
**Flat content goes on a quad layer** fixed in the room, drawn into one
persistent buffer and blitted into the swapchain image, because a swapchain
rotates through three images and the 2D path expects last frame's content to
still be there.
**Colour: the swapchains are `GL_SRGB8_ALPHA8` *and* sRGB write conversion is
disabled** via `GL_EXT_sRGB_write_control`. The format alone is half the fix and
looks like the whole of it — ES 3.0 converts on write into an sRGB attachment and
core ES has no switch to stop it, so the engine's already display-referred colour
was encoded a second time. It read as flat lighting. The corrected picture is
considerably darker, and that is correct.
**Orientation travels as axes, never Euler angles**, and position is measured
from where the head was when tracking started with the starting heading
subtracted.
**Head pose is applied in `RE_RenderScene`**, so it follows every camera the game
has — the player's, cutscenes, ladders, death animations.
### Placing things in the world, which is not obvious
`cg.refdef.vieworg` **is not the camera.** `R_VRComposeView` adds the head's play
space offset to it, rotated into the body's frame, after cgame is finished:
```c
bodyAngles = (0, viewYaw - baseYaw, 0);
fd->vieworg += vrView.origin[i] * bodyAxis[i];
```
Anything anchored on `vieworg` alone is displaced by exactly that offset, which
drifts as the head turns and does not survive a snap turn. So both hands are
reported measured from the **tracking origin** — the frame `VR_PoseToView` puts
the eyes in — and composed the same way the renderer composes the camera.
RTCWQuest reaches the same place by the other road: a head-relative offset,
rotated by `viewangles[YAW] - hmdorientation[YAW]` in `convertFromVR`, with its
height replaced by the `-64 + hmdposition[1] * worldScale` rebase. Either works.
**Half of each does not**: a head-relative offset composed as play space leaves
the hand at `vieworg + R*(hand - head)` while the camera is at `vieworg + R*head`,
so the head's motion is counted twice and every hand movement is amplified. That
is written up in `vr_common.h` above both accessors.
### Weapon and hands
**Aim comes from the controller, the camera stays on the head.**
`Weapon::GetMuzzlePosition` takes the controller's pitch and the yaw the hand
leads the head by, added to the view yaw the game already has — the controller's
absolute yaw is meaningless, since the player can snap turn and the play space
has an arbitrary forward. The angles reach `fgame` through `gameImport_t`, which
a dedicated server answers false for.
**The hands are separate surfaces on the first person model** — `triggerhand`,
`lefthand`, `garandhand`, `viewsleeves` on `USarmyplyr.skd` — so a hand per
controller needs no mesh work. The weapon hand's copy drops the left hand surface
and a second copy carries only that. `viewsleeves` is one mesh across both
forearms and cannot be split without re-authoring, so it stays with the weapon
hand.
**Each copy is anchored on a tag, not on the model origin**, by inverting the
composition `Entity::GetTagPositionAndOrientation` uses. The off hand anchors on
`Bip01 L Hand` and the weapon hand on `tag_weapon_right`, since what is held
there is the gun and the controller is its grip. Every candidate is measured and
logged rather than trusted — `tag_weapon_left` was assumed to be an unused slot
at the origin and is a real anchor 23.5 units out.
What is left after the frame and the anchor are right is the wrist bones' own
rotation convention, which is a constant: both hands need a roll of 180 in
`vr_weaponAdjust` / `vr_offHandAdjust`. That is the compiled default for both,
not something the config has to supply — the two hands are mirrored and were
still measured to want the same number, which is why they stay two cvars.
**Two handed hold**: off hand grip with the hands close together points the
weapon along the line between them, roll halved. **Reload** is a tap of the
weapon hand's grip. **Weapon switching** is the dominant thumbstick up and down,
gated on the sideways component and edge triggered.
### Debugging
```
vr_traceTracking 1 raw headset pose, tracking origin, world scale
r_vrTrace 1 the composed camera the renderer actually uses
vr_traceFrame 1 the per frame timing split (on by default; noisy)
vr_captureEye 1 writes the eye buffer to main/vrshotN.tga
```
**Start the logcat capture before handing the headset over.** The buffer is
shared with a shell that prints every five seconds, so a once-only startup line
is gone within a minute or two — that has cost two round trips:
```sh
adb logcat -c && adb logcat -s openmohaa:V > run.log &
```
The crash handler (`code/sys/new/sys_android_new.c`) reports the faulting address
and the `pc`/`lr` via `sigaction` + `SA_SIGINFO` on a private `sigaltstack`.
`_Unwind_Backtrace` cannot walk out of the kernel's signal trampoline, so before
that every crash reported four frames of the handler and nothing else.
---
## 4. Known problems
**A muzzle flash makes the ground darker, so the world's dynamic lighting is
off.** `r_worldDlights 0` in `autoexec.cfg`. Firing multiplies roughly a sixth
of the screen down by about 4× for as long as the trigger is held — the ground
plane, with hard polygon edges. Unsolved after seven wrong theories; every
component of the dynamic lightmap path measures correct end to end, and yet
switching the path off makes the symptom stop. Losing flashes lighting the
scenery is the smaller loss. The measurements, what is ruled out and where to
resume are in `docs/markdown/04-coding/05-dlights.md`.
The lesson worth carrying beyond this bug: **difference the frames first.**
Pulling a capture, extracting every frame and differencing a flash frame against
its neighbour gave the cadence, the 4× ratio and the affected geometry in one
step, after six rounds of reading the renderer had given nothing. Also, never
record video and read the log in the same run — the Quest's recorder floods
logcat and evicts everything.
**It does not hold 90 Hz while moving.** `vr_resolutionScale` is now 1, so the
eyes render at the full 1680×1760, and the frame budget is spent:
| | fps | eye pass |
|---|---|---|
| standing at spawn | 90.0 | 8–9 ms |
| moving in the Bocage | 77–85 | 11–12 ms |
The display period is 11.1 ms, so while moving most frames overshoot it and the
compositor reprojects them. **Kept deliberately** — it plays well, the picture is
much sharper, and the reprojection is not felt. But it is a known trade rather
than headroom, and `vr_resolutionScale` is the first thing to give back if
something later needs the time.
**There is no budget for MSAA.** It is wired up and switched off (`vr_samples 0`)
and it resolves in tile memory on Adreno so it is nearly free on the GPU — but
the GPU is not the constraint, and there is nothing spare on the CPU side. This
stays off until the frame split below is trustworthy.
**The frame split cannot be taken at face value.** It reports `world 1ms` against
`hud 5ms`, which is not credible for a full world render. `Set2DWindow` begins
with `R_IssuePendingRenderCommands()`, so the world's deferred draw calls are
attributed to the first 2D call that forces them out — the trap in
`QUEST_PORTING_GUIDE.md` §7.1. The one trustworthy figure is `gpuwait`, still
**3 ms**: the GPU finishes early and waits, exactly as it did under rend2, so
whatever the 11 ms is, it is CPU issuing draw calls and not fill rate.
**Fixing that attribution is the prerequisite to spending any budget**, because
right now nobody can say where half the frame goes.
**The HUD is still screen space during gameplay**, with the same non-convergence
the menus had before the quad layer.
**Per-weapon alignment is one global setting.** `vr_weaponAdjust` is shared
across every weapon, but a Garand, a Colt and a bazooka will not want the same
grip offset — the reference keeps `vr_weapon_adjustment_<id>` per weapon. The
Bocage hands over a Colt and a Garand at spawn, plus `give all`, which is the
place to find out how far apart they need to be.
**Not confirmed on the device**, and each is only known to compile:
- the two handed hold and the reload gesture
- off hand steering — it was mirrored, and the corrected sign has not been tried
- whether the pistol's `lefthand` surface looks right; only the Garand's
`garandhand` has been seen
**Some foliage still rotates with head movement.** `AutospriteDeform` and the
`SPRITE_PARALLEL` paths now face the viewer's position rather than the view
plane, which fixed the near foliage and the imposters behind it. At least one
more path does the same thing. `Autosprite2Deform` was left alone on purpose.
**A recurring SIGSEGV in the Adreno driver** (`gsl_memory_alloc_pure_64`, fault
address `0x90`) on a secondary thread at every launch. It **predates all VR
work** — it was in the flat builds too — and the process survives it every time.
Establish which faults are background noise before bisecting anything.
---
## 5. What to do next
1. **Fix the frame split**, so `world` and `hud` mean what they say. Half the
frame is currently attributed to a flush rather than to the work that caused
it, and every performance decision after this one depends on knowing where
the 11 ms actually goes. The GPU idling at 3 ms says there is something real
to find.
2. **Then decide about MSAA**, and about whether the CPU cost can come down far
enough to hold 90 Hz at full resolution rather than reprojecting to it.
3. **Confirm the unconfirmed** — the two handed hold, the reload tap, off hand
steering. All three are one session on the device with the harness already in
place.
4. **Per-weapon alignment.** Build the per-weapon table once the numbers for two
or three weapons are known.
5. **The HUD.** Wrist or weapon mounted readouts; `VR_WristPanelEnabled` already
exists and is off.
6. Remaining foliage paths, and the Adreno fault.
### Reference
Team Beef's RTCWQuest, `/home/berkybear/RTCWQuest`, clean at `4c714b9`.
`Projects/Android/jni/RTCWVR/`: `TBXR_Common.c` is their OpenXR layer,
`VrInputDefault.c` the movement and weapon handling, `VrInputWeaponAlign.c` the
per-weapon calibration. `rtcw/src/cgame/cg_weapons.c` has
`CG_CalculateVRWeaponPosition` and the offhand equivalents;
`rtcw/src/renderer/tr_scene.c` has `convertFromVR`, which is the frame conversion
this port arrives at from the other direction.
Ideas there worth having and not yet taken: **a projection layer and a quad layer
in the same frame** (`TBXR_Common.c:1576`), which is how a pointer ray and
controller models get drawn on a menu-only frame; and **scope zoom by narrowing
the composition layer's FOV** (`TBXR_Common.c:1557`) rather than touching the
projection matrix, which applies directly to the sniper scopes and binoculars.
Not there, so do not go looking: no foveation, no space warp, no performance
level hints. `XR_FB_display_refresh_rate` is vestigial in their tree — the
working implementation here is original.
+546
View File
@@ -0,0 +1,546 @@
# Swapping the renderer — where this stands and what to do next
Companion to `VR_PORT_STATUS.md`. That file describes the VR layer; this one is
about the move from `renderergl2` to `renderergl1` on gl4es.
Branch `vr-quest3`.
---
## 0. Status at a glance
**Working, on the device, with nothing overridden:** renderergl1 on gl4es, the
OpenXR session, both eyes and the flat panel, the menus, cutscenes, controllers
and pointer beam, **and the world** - terrain, BSP geometry, static models,
textures, lightmaps, fog and sky. **89-90 fps at 6 ms per eye**, against rend2's
50-60 ms. That is the number this whole swap existed to get.
Three bugs were behind the black screen, and each is recorded in section 8 with
what proved it:
1. A window-resize `vid_restart` tearing down the GL context and the OpenXR
session five seconds into every run. That was the black menu.
2. `RB_SurfaceFace` writing 32-bit indices through a 16-bit array. That was the
world smeared into streaks.
3. **gl4es's cached cull face drifting from the driver's**, so the engine's
`glCullFace(GL_FRONT)` was dropped as redundant every frame while the
hardware culled `GL_BACK` - exactly the faces Quake keeps. That was the
invisible world.
**The single most important lesson is section 9.** Everything the engine could
be asked about itself said the renderer was correct, because under gl4es the
engine is largely asking gl4es to repeat the engine's own values back.
---
## 1. The reference implementation — read this before solving anything
**Team Beef's RTCWQuest is cloned at `/home/berkybear/RTCWQuest`**, clean at
`4c714b9`.
Same Quake 3 engine lineage, same genre, same headset, same gl4es — and unlike
this port, it is finished. **Go to it first for any problem this port is trying
to solve.** That is a standing instruction, not a suggestion. In this session it
was violated repeatedly and cost most of the elapsed time: the answer to the
single biggest blocker (section 5.8) was sitting in their `Android.mk` the whole
time.
| Path (under `Projects/Android/jni/`) | What |
|---|---|
| `RTCWVR/TBXR_Common.c` | their OpenXR layer: session, swapchains, composition layers, frame submit |
| `RTCWVR/VrInputDefault.c` | movement, gestures, weapon handling |
| `RTCWVR/VrInputWeaponAlign.c` | per-weapon aim calibration |
| `RTCWVR/VrClientInfo.h` | their whole VR state struct |
| `rtcw/src/renderer/` | the fixed function renderer with their GLES changes (`#ifdef HAVE_GLES`) |
| `SupportLibs/gl4es/Android.mk` | **the gl4es build flags. These matter. See 5.8.** |
### Already extracted — do not re-derive
**Aiming** (`rtcw/src/game/g_weapon.c:1904`). Shot direction is computed in the
*game*, where the trace is fired:
```c
VectorCopy(gVR->weaponangles, viewang);
viewang[YAW] = ent->client->ps.viewangles[YAW]
+ (gVR->weaponangles[YAW] - gVR->hmdorientation[YAW]);
```
Pitch and roll come straight from the controller; yaw is the view yaw plus how
far the controller leads the head. The controller's absolute yaw is meaningless.
The camera stays on the head; only the trace moves to the hand.
**6DoF** (`rtcw/src/client/cl_input.c:847`). Head position delta is fed into the
usercmd as movement. Already ported here.
**The HUD.** No wrist panel. Screen space, in the eye buffers, re-centred on
each eye's off-centre frustum, given stereo parallax, scaled in from the edges.
**Hands.** No controller models. Their existing view-model hand, moved to the
controller's real-world offset from the head.
**Ladders.** Nothing at all — RTCW's are contents-based so it works for free.
**Does not transfer**: MOH:AA is entity-based with a view clamp in
`PmoveAdjustViewAngleSettings_OnLadder`.
**Not there, so do not go looking**: no foveation, no space warp, no performance
level hints. `XR_FB_display_refresh_rate` is vestigial in their tree.
---
## 2. Why the renderer was swapped
renderergl2 ran at **10–15 fps against a 90 Hz display**. Taken apart on the
device:
| Phase | Cost |
|---|---|
| `xrWaitFrame` | 0 ms |
| Building the 3D scene | 2–3 ms |
| **Issuing the scene to GL** | **50–60 ms, per eye** |
| GPU, measured with `glFinish` | **3 ms** |
The GPU finishes in 3 ms and waits. Rendering at 36% of the pixels bought only
23% back, so it is not fill rate. It is CPU in the back end issuing draw calls:
rend2 puts every surface through a GLSL program with dozens of uniforms, 139
`GLSL_BindProgram`/`GLSL_SetUniform` call sites in the surface path, once per
surface per eye.
None of what that buys is visible here — this art has no normal or specular
maps. RTCWQuest's renderer contains no GLSL at all: the original Quake 3 forward
renderer on gl4es. That is the stack being copied.
---
## 3. Measurement traps in the *old* renderer investigation
Kept because they are still true of the code.
**`backEnd.pc.msec` is assigned, not accumulated** (`tr_backend.c`). Later empty
flushes overwrite the scene's real number with 0.
**`Set2DWindow` begins with `R_IssuePendingRenderCommands()`**. The 3D scene is
*built* during the world pass but only *executed* when something forces a flush,
and the first thing that does is the HUD. The whole frame's rendering therefore
appears to any timer to be HUD cost.
**`tr.frontEndMsec` only covers `RE_RenderScene`**, not the cgame work around it.
---
## 4. Build
```sh
cmake -B build-gl1 -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-29 -DANDROID_STL=c++_shared \
-DCMAKE_BUILD_TYPE=Release -DBUILD_SERVER=OFF
cmake --build build-gl1 -j$(nproc)
cd misc/android && gradle assembleDebug -PnativeLibsDir=../../build-gl1/apk-libs
cd ../.. && adb install -r misc/android/app/build/outputs/apk/debug/app-debug.apk
```
renderergl1 + gl4es is the default on Android. renderergl2 is still reachable
with `-DBUILD_RENDERER_GL1=OFF -DBUILD_RENDERER_GL2=ON -DUSE_GL4ES=OFF` and
still builds.
---
## 5. What was wrong, and what fixing it required
All committed. Each of these was a hard blocker in its own right.
### 5.1 renderergl1 had never compiled
`cmake/platforms/android.cmake` held `set(BUILD_RENDERER_GL1 OFF CACHE INTERNAL "")`.
**`CACHE INTERNAL` implies `FORCE`**, so the `-D` on the command line was
overwritten on every configure. The cache read `BUILD_RENDERER_GL1:INTERNAL=OFF`
and `build.ninja` never mentioned renderergl1. A previous handoff claimed this
renderer "compiles clean"; it had never been compiled at all. **Check the cache,
not the command line.**
### 5.2 gl4es was building into the source tree, under a name Android cannot load
gl4es's own CMakeLists sets `CMAKE_LIBRARY_OUTPUT_DIRECTORY` to
`${CMAKE_SOURCE_DIR}/lib`, which under FetchContent is *this* project's source
root. It also forces the suffix `.so.1`. Android has no versioned sonames and
the package manager extracts only `lib*.so`. Redirected into the build tree as
`libgl4es.so` and staged with the other APK libraries.
### 5.3 The renderer's GL entry points must come from gl4es
renderergl1 does not call GL by name. `qgl_linked.h` is dead code that nothing
includes; `renderercommon/qgl.h` declares every entry point as a **function
pointer** filled by `GLimp_GetProcAddresses` from `SDL_GL_GetProcAddress`.
On **EGL 1.5, which the Quest is**, `SDL_EGL_GetProcAddress` tries
`eglGetProcAddress` *before* the library it loaded. The driver answers for every
name OpenGL 1.x and ES have in common — `glEnable`, `glBindTexture`,
`glDrawArrays`, `glTexImage2D`, ~80 more — so only desktop-only names
(`glBegin`, `glMatrixMode`) would reach gl4es. The renderer now resolves through
`GLimp_GetProcAddress`, straight to the gl4es handle.
The **extension string** has the same problem: `SDL_GL_ExtensionSupported` reads
the driver's ES list, which names none of the desktop extensions the renderer
asks after, so `GL_ARB_multitexture` read as absent and the renderer would have
dropped to one texture unit.
### 5.4 The fixed function path asked for a context that does not exist
`GLimp_SetMode`'s `fixedFunction` branch offered exactly one context: **desktop
OpenGL 1.1**. There is no such thing on Android; window creation failed and
R_Init gave up with `Couldn't get a visual`. renderergl2 never met this because
it takes the other branch.
Under `USE_GL4ES` that branch now asks for **ES 3.0 then ES 2.0**. The renderer
is never told: it reads its version through gl4es, which reports desktop GL
(`2.1 gl4es wrapper 1.1.6`), and takes the desktop fixed function path on the
strength of it. ES underneath, desktop GL above — the whole point of the layer.
### 5.5 gl4es answers `glGetString` from constants
`GL_VERSION`, `GL_VENDOR` and `GL_RENDERER` come out of gl4es's own globals
without the driver being asked. The engine's only check that a context is live is
`GL_VERSION` coming back non-NULL, so under gl4es that check passes
unconditionally and a context created but never bound sails through `R_Init`.
`GLimp_SetMode` now asks SDL directly instead.
### 5.6 SDL unbinds the context and reports success
`SDL_EGL_MakeCurrent`, handed no surface and without `gl_allow_no_surface`:
```c
if (!egl_context || (!egl_surface && !_this->gl_allow_no_surface)) {
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
}
return 0; /* success, either way */
```
In a headset there is no window being presented, so that surface never arrives.
SDL then records the context as current, and SDL and EGL disagree permanently.
Asking SDL to bind it again does nothing — `SDL_GL_MakeCurrent` sees its own
bookkeeping agree and returns early with `/* We're already current. */`.
RTCWQuest never meets this because it never depends on a window surface: it makes
its context current against a **16×16 pbuffer** (`TBXR_Common.c`,
`egl->TinySurface`). Same here, with the context SDL already made. The pbuffer
outlives the session deliberately and is released in `VR_Shutdown`.
### 5.7 gl4es must own the `GL_FRAMEBUFFER` binding — and only that
gl4es keeps its own table of framebuffers it created and **renders from that,
not from the driver's binding**:
```c
gles_glBindFramebuffer(GL_FRAMEBUFFER, current_fb->id ? current_fb->id : mainfbo_fbo);
```
A framebuffer from the driver's `glGenFramebuffers` is not in that table, so
`gl4es_glBindFramebuffer` raises `GL_INVALID_VALUE`, **returns without binding**,
and leaves `current_fb` at zero. Measured directly:
```
fbo: gl4es says 0, driver says 10 | driver viewport 756 -71 403 1126
```
Zero in this process is the 16×16 pbuffer, and the viewport lies entirely outside
it — so geometry landed in nothing, with no error raised anywhere.
The VR layer now has gl4es generate, bind and delete these. **Three things must
stay with the driver:**
- the **swapchain texture attachment** — gl4es cannot attach a texture the
OpenXR runtime created. RTCWQuest hits this and documents it at
`TBXR_Common.c:290`, describing our exact symptom: *"the engine then renders
into nothing -> black eye buffer"*. (They must bypass gl4es explicitly because
ndk-build puts gl4es first in their link order; ours is already native because
`libGLESv3` precedes `libgl4es` in `DT_NEEDED`.)
- the **blit's `GL_READ_FRAMEBUFFER` / `GL_DRAW_FRAMEBUFFER` bindings** — gl4es
takes `GL_READ_FRAMEBUFFER` as a note to itself and `return`s without binding
(`framebuffers.c:238`). Routing the blit through it silently empties the blit
and **kills the cutscenes**. This was done once by accident with an
over-broad macro; do not repeat it.
- `glFramebufferTexture2D`, per the first point.
`RE_SetDefaultFramebuffer` in renderergl1 binds **nothing**. It exists only to
flush: gl4es batches geometry and issues it lazily, so work built for one target
would otherwise arrive in whichever is bound when it finally goes out. The VR
layer calls it *before* its own bind for that reason.
### 5.8 gl4es must be built the way the reference builds it
**This was the largest single blocker and it is the thing to remember.**
With CMake's own flags, gl4es **rendered no geometry whatsoever** while clearing
the same framebuffer perfectly. Measured, in the same buffer microseconds apart:
```
gl4es draw test: 144 red (clear), 0 green (drawn), 0 other
```
Matching RTCWQuest's `Android.mk` fixed it outright, with no engine-side change:
```
gl4es draw test: 108 red (clear), 36 green (drawn), 0 other
```
(36/144 is exactly the quarter the test quad covers.)
Their flags, now in `cmake/libraries/gl4es.cmake`:
```
-DBCMHOST -DNOX11 -DNO_GBM -DDEFAULT_ES=2 -DNO_INIT_CONSTRUCTOR
-O3 -fcommon -fvisibility=hidden -funwind-tables
```
**`ANDROID` is deliberately not among them.** gl4es's own CMakeLists adds
`-DANDROID` whenever the NDK toolchain is in use, and ndk-build never does — so
the reference has only ever been exercised without it. It is not cosmetic: it
selects different code in the loader, in glx, and in `hardext`, which is where
gl4es compiles a probe shader (`testGLSL`) to decide what its fixed function
pipeline may emit. It comes from `add_definitions()`, a **directory** property,
so it must be removed at the directory it was set on — overriding it on the
target does not work.
**Still to do: bisect which flag actually mattered.** `-DANDROID` is the strong
suspicion. This is currently convergence on a known-good configuration, not a
diagnosis.
---
## 6. The trap that cost this session, in bold
**Every engine-side experiment run before 5.8 was worthless, because gl4es could
not draw anything at all.**
Under that broken gl4es, *any* test that ends "and nothing was drawn" returns the
same answer regardless of what it is testing. On the strength of such results
this session wrongly concluded, and acted on:
- that 32-bit indices were the problem (changed to 16-bit — see 8.1)
- that the `glDrawElements` client-array path was broken (switched to
`qglArrayElement`; no change)
- that compiled vertex arrays were broken (disabled them — see 8.2)
- that `glDrawBuffer(GL_BACK)` on an FBO was discarding everything (stubbed it —
see 8.3)
- that immediate mode worked while arrays did not, and later that *neither*
worked — two contradictory conclusions, both from invalid data
**Before believing any negative rendering result, prove the renderer can draw at
all.** The cheapest possible check, and the one that finally cracked this:
ask gl4es for a `glClear` and then a flat untextured quad into the same buffer,
and read the pixels back. A clear needs no shaders; a draw needs the whole
pipeline. If the clear lands and the draw does not, stop looking at the engine.
A second, dumber version of the same mistake: a probe gated on
`primitives == 2` silently stopped running when a separate change flipped
`primitives` to 1, and two rounds of "nothing drawn" results were actually
"the probe never executed". **Verify instrumentation still fires after changing
anything it depends on.**
---
## 7. What is now positively proven working
Measured on the device, after 5.8. Do not re-investigate these.
- The OpenXR session, both eye swapchains and the flat quad layer. 90 fps.
- The panel framebuffer is real, complete (`GL_FRAMEBUFFER_COMPLETE`), writable
and composited — a clear to blue showed as blue in the headset.
- gl4es reaches that framebuffer: a clear to red showed as red.
- gl4es renders geometry: a flat quad rendered, verified by pixel readback.
- ~~**The engine's own geometry renders** — with a flat-red override, the intro
logo screen before the cutscene appeared red in the headset.~~ **Weaker than
it reads.** The override had disabled the colour array, and the route it went
down was `qglArrayElement`, not `glDrawElements`. It proves one array reaches
gl4es on one route, not that the renderer's geometry path works. See 8.1.
- Cutscenes render (immediate mode, `RE_StretchRaw`).
- Controllers and the pointer beam render (VR layer's own GLES).
- Both layers agree on the framebuffer: `gl4es says 10, driver says 10`.
- The 2D setup is correct: viewport `0 0 1008 1056`, ortho `0 1008 1056 0`,
full scissor, `vrView.active` false during menus (so the HUD convergence code
in `R_VRAdjust2DOrtho` is *not* involved in the menu at all).
- The geometry submitted for menu widgets is correct: 4 verts, 6 strip indices,
sensible coordinates, identity modelview, a correct ortho matching the
widget's own viewport, `glGetError() == 0` on every draw.
---
## 8. Fixed this session
**8.1 The menu was a renderer restart.** `sdl_input.c`'s `SDL_WINDOWEVENT_RESIZED`
handler compares the window against `cls.glconfig.vidWidth/Height`, which in VR
is the *eye buffer*, never the Android surface. It can never match, so it set
`r_customwidth`/`r_customheight`/`r_mode -1` and scheduled a `vid_restart` about
five seconds in - cvars the renderer then ignores, because the VR resolution
comes from `GetVRRenderResolution`. The restart destroyed and rebuilt the GL
context and the OpenXR session under a running frame loop, and section 5.7's
framebuffer agreement did not survive it. Measured: the menu drew a full
`256/256` panel samples right up to `CL_Vid_Restart_f` and nothing ever after.
Window-resize restarts are now declined under `VR_Enabled()`.
`VR_PORT_STATUS.md` called this restart "harmless ... but wasteful". It was the
entire bug.
**8.2 `RB_SurfaceFace` wrote 32-bit indices into a 16-bit array.** It declared
`unsigned *tessIndexes` and assigned `tess.indexes` to it - which is
`glIndex_t *`, i.e. `unsigned short *` under gl4es (9.1). Every index went in
four bytes apart in a two-byte array, so half the slots held the zero high
halves of their neighbours and every triangle picked up whatever vertex that
landed on. That was the stretched, smeared world that dragged its textures with
it. Models never come through this function, which is why they were perfect
throughout.
`ProjectDlightTexture`'s `unsigned hitIndexes[SHADER_MAX_INDEXES]`, passed
straight to `R_DrawElements`, had the same fault. Both fixed; a full rebuild now
reports no index-type mismatches in the renderer.
**The compiler had been saying so all along**, under sixty-odd other warnings:
```
tr_surface.c:401: warning: incompatible pointer types assigning to 'unsigned int *'
from 'glIndex_t *' (aka 'unsigned short *')
```
This is the cost of 9.1 that nobody checked for: changing `glIndex_t` is not
local to the draw call, and the only thing that finds the other users is the
warning log. **Read it after touching that typedef.**
**8.3 The frame budget, which is what the swap was for.** In-game, on the
device: **90 fps at 4 ms per eye**, against rend2's 50-60 ms (section 2). That
question is answered and `vr_resolutionScale` can go back to 1.
**8.4 Smaller, all real.** renderergl1's `R_SetupFrustum` culled against the
game's symmetric fov rather than the runtime's asymmetric per-eye frustum
(renderergl2 does not have this bug - its `R_SetupFrustum` takes the projection
edges). Every `glViewport`, `glEnable/glDisable`, `glDepthMask`, `glDepthFunc`,
`glBlendFunc`, `glColorMask`, `glCullFace` and `glScissor` in the VR layer now
goes through gl4es, because gl4es caches all of them and silently drops the
engine's next request for a value its stale copy already claims. `GfxInfo_f`
printed a different draw route than `R_DrawElements` actually took.
---
## 9. Ask the driver, not gl4es
**Written up properly in `docs/markdown/04-coding/04-gl4es.md`.** That file is
the reference; this section is the short form.
**gl4es answers state queries from its own tables.** `glGetIntegerv` for
`GL_CULL_FACE_MODE`, `GL_FRONT_FACE` and `GL_MODELVIEW_MATRIX` all come out of
`glstate`, not the driver (`src/gl/getter.c:636-902`) - the same way `glGetString`
comes from constants (5.5). So reading state back through `qgl` and comparing it
against the engine compares the engine with gl4es's copy of the engine. It
agrees by construction and proves nothing.
Two measurements in this session did exactly that and were reported as findings:
"the matrices are identical, `maxdiff 0.000`" and "GL and the engine agree on
culling". Both were vacuous. The second was actively harmful - the driver was
culling `GL_BACK` while gl4es reported the `GL_FRONT` the engine had asked for,
and that *was* the bug being hunted.
**gl4es also drops calls its cache judges redundant** - `glCullFace`
(`face.c:14`), `glDepthMask`, `glDepthFunc`, `glBlendFunc`, `glColorMask`,
`glScissor`, `glViewport`. Once its copy drifts, the engine can never correct it:
it asks for the right value, gl4es compares against the wrong copy, and the
driver never hears. Nothing raises an error and nothing looks wrong from inside.
**So: resolve `glGetIntegerv`/`glIsEnabled` out of `libGLESv3` and ask the
hardware.** A temporary `r_traceSurf` probe did this, printing both answers
side by side; it has since been removed, but the technique is
what cracked the cull bug in one run after many that measured nothing.
The same applies to setting state, which is what `GL_Cull` now does: name a
throwaway value first so the real one cannot match a stale cache.
### 9.1 Probes must be shown to reach the thing they measure
Three probes in this session had blind spots exactly where the fault was, and
each produced a confident wrong conclusion:
- the panel readback counted samples brighter than 8 against a buffer that is
never cleared, so "nothing drawn" and "drawn black" were the same number;
- `r_flatColor` did not apply to multitextured surfaces, so it covered models
only - every "the world is not drawing" reading from it was about geometry
that was never in question;
- the flat-red override restored its state inside `if (primitives == 2)`, a
branch that was never taken, so it corrupted the renderer it was measuring.
**Before believing a negative result, prove the probe reached the surfaces in
question.** This is section 6's lesson in a second form and it cost more here
than the bugs did.
### 9.2 Single runs are not evidence
The same configuration produced different results on different runs more than
once, and a bisect was built on top of that before it was noticed. Repeat a
result before building on it, and never change code and configuration in the
same step - doing so destroyed the only known-good baseline and cost several
round trips to rebuild.
## 10. Working notes
**The device configuration lives at `misc/android/autoexec.cfg`.** It holds the
nothing load bearing any more - the overrides it used to carry went with the
bugs they worked around. It is not part of the APK; deploy it next to the game
data:
```sh
adb push misc/android/autoexec.cfg \
/sdcard/Android/data/org.openmoh.openmohaa/files/main/autoexec.cfg
adb shell am force-stop org.openmoh.openmohaa
```
**`autoexec.cfg` is the only way to set a cvar on this device.** There is no
console in a headset. It is exec'd by `Com_ExecuteCfg` after `omconfig.cfg`,
cannot be loaded out of a pk3 (`FS_FOpenFileRead`'s `isLocalConfig`), and lands
*before* `R_Init` - so the renderer's `Cvar_Get` finds each cvar already present
and keeps this value instead of its own default. That covers `CVAR_LATCH` and
`CVAR_ARCHIVE` cvars too. It cannot reach `VR_TuningCvar` cvars, which force
their value at every start, and `CVAR_CHEAT` cvars get reset on map load.
**Set every knob explicitly, never by omission.** A value left out is whatever
`omconfig.cfg` last archived. `cg_shadows` cost a whole round trip that way: it
was already 0 in the stored config, so the run that was supposed to test
disabling shadows tested nothing at all. **Read the stored config before
believing a negative result** - `adb pull .../files/main/configs/omconfig.cfg`.
**`adb install -r` does not kill a running process.** The old code keeps running
and the next launch resumes it, so a run can silently test the previous build.
Always `adb shell am force-stop org.openmoh.openmohaa` after installing.
**Pull captures individually, not the directory.** `adb pull .../files/main/`
drags 1.6 GB of pk3s. `adb pull .../main/vrshot0.tga` is what you want.
**Device.** `adb connect 192.168.1.92:43105`. It sleeps and the connection dies;
`adb disconnect` then `adb connect`. **Launch from inside the headset** — never
`adb shell am start`.
A logcat wrapper that survives the sleep/wake cycle is worth keeping around; the
connection drops constantly and a plain `adb logcat` dies with it.
**Archived cvars beat a changed default.** The config is exec'd before `VR_Init`,
so `Cvar_Get` finds the cvar already there and keeps the old value. `VR_TuningCvar`
in `vr_openxr.c` exists for this: it registers unarchived and forces the default
every start. There is no console in a headset to correct a stale value with.
**There is no console in a headset.** Every question has to be answered by
something written to logcat, or by something visible enough to describe. Prefer
pixel readbacks logged as numbers over anything that has to be seen — asking
someone to catch a one-frame flash is a bad test, and was one here.
**Settled, so not re-investigated:**
- `XR_FB_display_refresh_rate` works; the display runs at 90 Hz. Ask for it from
the frame loop with the two-call enumeration, not at `xrBeginSession`.
- MSAA via `GL_EXT_multisampled_render_to_texture` works and is off
(`vr_samples 0`).
- `vr_resolutionScale` is at **0.6** and should go back to 1 once the picture is
up — fill rate was never the problem.
- A recurring `Fatal signal 11` at address `0x90` on a secondary thread at every
launch **predates all VR work** and the process survives it every time.
**Once the menu draws**, the number this whole swap was for is still unmeasured:
renderergl1's back-end cost per eye against rend2's 50–60 ms. Section 2 is the
baseline to beat. The instrumentation for splitting a VR frame into
wait/scene/issue/gpu is described in `VR_PORT_STATUS.md`.
+11 -1
View File
@@ -12,7 +12,7 @@ file(GLOB_RECURSE BG_SOURCES
${SOURCE_DIR}/fgame/bg_voteoptions.cpp
)
file(GLOB_RECURSE CGAME_SOURCES
file(GLOB_RECURSE CGAME_SOURCES CONFIGURE_DEPENDS
${SOURCE_DIR}/cgame/*.c ${SOURCE_DIR}/cgame/*.cpp
)
@@ -81,6 +81,12 @@ if(BUILD_GAME_LIBRARIES)
set_target_properties( ${CGAME_MODULE_BINARY_BASEGAME} PROPERTIES OUTPUT_NAME ${CGAME_MODULE_BINARY})
set_output_dirs( ${CGAME_MODULE_BINARY_BASEGAME} SUBDIRECTORY ${BASEGAME})
if(ANDROID)
# Shipped in the APK, so it needs the prefix to be extracted at all
# (see DLL_PREFIX in q_platform.h)
set_target_properties( ${CGAME_MODULE_BINARY_BASEGAME} PROPERTIES PREFIX ${ANDROID_APK_LIBRARY_PREFIX})
endif()
INSTALL(TARGETS ${CGAME_MODULE_BINARY_BASEGAME} DESTINATION ${INSTALL_LIBDIR_FULL})
if(MSVC)
@@ -94,6 +100,10 @@ if(BUILD_GAME_LIBRARIES)
set_target_properties( ${GAME_MODULE_BINARY_BASEGAME} PROPERTIES OUTPUT_NAME ${GAME_MODULE_BINARY})
set_output_dirs( ${GAME_MODULE_BINARY_BASEGAME} SUBDIRECTORY ${BASEGAME})
if(ANDROID)
set_target_properties( ${GAME_MODULE_BINARY_BASEGAME} PROPERTIES PREFIX ${ANDROID_APK_LIBRARY_PREFIX})
endif()
INSTALL(TARGETS ${GAME_MODULE_BINARY_BASEGAME} DESTINATION ${INSTALL_LIBDIR_FULL})
if(MSVC)
+9 -2
View File
@@ -42,7 +42,6 @@ set(CLIENT_SOURCES
${SOURCE_DIR}/client/cl_uisoundpicker.cpp
${SOURCE_DIR}/client/cl_uistd.cpp
${SOURCE_DIR}/client/cl_uiview3d.cpp
${SOURCE_DIR}/client/libmumblelink.c
${SOURCE_DIR}/client/qal.c
${SOURCE_DIR}/client/snd_codec_mp3.c
${SOURCE_DIR}/client/snd_codec_ogg.c
@@ -106,7 +105,15 @@ list(APPEND CLIENT_BINARY_SOURCES
${ASM_SOURCES}
${CLIENT_LIBRARY_SOURCES})
add_executable(${CLIENT_BINARY} ${CLIENT_EXECUTABLE_OPTIONS} ${CLIENT_BINARY_SOURCES})
if(ANDROID)
# SDLActivity dlopen()s the engine and calls SDL_main, so the client is a
# shared library rather than an executable. It also has to carry the lib
# prefix: the package manager only extracts lib*.so out of an APK.
add_library(${CLIENT_BINARY} SHARED ${CLIENT_BINARY_SOURCES})
set_target_properties(${CLIENT_BINARY} PROPERTIES PREFIX ${ANDROID_APK_LIBRARY_PREFIX})
else()
add_executable(${CLIENT_BINARY} ${CLIENT_EXECUTABLE_OPTIONS} ${CLIENT_BINARY_SOURCES})
endif()
target_include_directories( ${CLIENT_BINARY} PRIVATE ${CLIENT_INCLUDE_DIRS})
target_include_directories( ${CLIENT_BINARY} PRIVATE ${SOURCE_DIR}/client)
+2 -1
View File
@@ -43,8 +43,9 @@ list(APPEND SERVER_LIBRARIES m)
if(COMPILER_OUTPUT MATCHES "musl|freebsd|openbsd|netbsd")
list(APPEND CLIENT_LIBRARIES execinfo)
list(APPEND SERVER_LIBRARIES execinfo)
elseif (NOT APPLE)
elseif (NOT APPLE AND NOT ANDROID)
# For when using GLIBC versions older than 2.34
# (bionic has no librt; the same symbols live in libc)
list(APPEND CLIENT_LIBRARIES rt)
list(APPEND SERVER_LIBRARIES rt)
endif()
+1 -1
View File
@@ -1,5 +1,5 @@
if(NOT APPLE)
if(NOT APPLE AND NOT ANDROID)
# Configure the .desktop entries with the arch suffix
configure_file(
misc/linux/org.openmoh.openmohaa.desktop.in
+6
View File
@@ -1,3 +1,9 @@
if(ANDROID)
# The launcher picks a game binary to exec; on Android the activity is
# the entry point instead.
return()
endif()
set(LAUNCHER_SOURCES "${SOURCE_DIR}/Launcher/launch_main.cpp")
if (WIN32)
+2
View File
@@ -4,6 +4,8 @@ include(libraries/jpeg)
include(libraries/ogg)
include(libraries/opus)
include(libraries/openal)
include(libraries/openxr)
include(libraries/gl4es)
include(libraries/sdl)
include(libraries/vorbis)
include(libraries/zlib)
+125
View File
@@ -0,0 +1,125 @@
if(NOT ANDROID OR NOT BUILD_CLIENT OR NOT USE_GL4ES)
return()
endif()
# gl4es translates the fixed function OpenGL the original renderer speaks into
# the OpenGL ES the device actually has.
#
# This is here because renderergl1 is the renderer this game's art was authored
# for. renderergl2 is the rend2 rewrite: every surface goes through a GLSL
# program with dozens of uniforms set on it, once per surface per eye, and the
# frame timing found tens of milliseconds of CPU going into that while the GPU
# finished in three. None of what it buys is visible on content that has no
# normal or specular maps for it to read - so it costs the frame and returns a
# picture further from the original than the plain path gives.
#
# The alternative was to port renderergl1's fixed function calls to ES by hand.
# That is the same work gl4es already does, more thoroughly, and it is the same
# choice RTCWQuest made for the same reason.
include(FetchContent)
set(GL4ES_VERSION 1.1.6)
set(GL4ES_SOURCE_PATH "" CACHE PATH
"Existing gl4es source tree to build against; downloaded if empty")
if(GL4ES_SOURCE_PATH)
FetchContent_Declare(gl4es SOURCE_DIR ${GL4ES_SOURCE_PATH})
else()
FetchContent_Declare(gl4es
GIT_REPOSITORY https://github.com/ptitSeb/gl4es.git
GIT_TAG v${GL4ES_VERSION}
GIT_SHALLOW TRUE)
endif()
# No X11 and no GBM on Android, and ES 2 as the backend. NO_INIT_CONSTRUCTOR
# keeps it from initialising itself behind the engine's back: the context is
# created by SDL and handed to OpenXR, and gl4es has to attach to that one
# rather than make its own. These are the flags RTCWQuest ships.
set(NOX11 ON CACHE BOOL "" FORCE)
set(GBM OFF CACHE BOOL "" FORCE)
set(STATICLIB OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(gl4es)
# The target is called GL, because that is what gl4es presents itself as - it
# takes over the OpenGL entry points the renderer calls. Named back to gl4es on
# disk so the APK does not ship something calling itself libGL.so, which is a
# name the platform has its own ideas about.
if(NOT TARGET GL)
message(FATAL_ERROR
"gl4es did not declare its GL target; nothing would translate the GL1 "
"renderer's calls. Configure with -DUSE_GL4ES=OFF to build without it.")
endif()
# Built the way RTCWQuest builds it, because that combination is known to render
# on this device and this one does not.
#
# Their Android.mk (SupportLibs/gl4es/Android.mk) compiles with exactly:
#
# -DBCMHOST -DNOX11 -DNO_GBM -DDEFAULT_ES=2 -DNO_INIT_CONSTRUCTOR
# -O3 -fcommon -fvisibility=hidden -funwind-tables
#
# Two differences from what CMake produces on its own are worth naming.
#
# ANDROID is *not* among them. gl4es's own CMakeLists adds it whenever it is
# built with the NDK toolchain, and it is not a harmless label: it selects
# different code in the loader, in glx, and in hardext - which is where gl4es
# compiles a probe shader to decide what its whole fixed function pipeline is
# allowed to emit. ndk-build never defines it, so the reference has been
# exercised without it and this has not.
#
# BCMHOST is the Raspberry Pi switch, which looks wrong on a Quest and is
# carried anyway, because everything it gates is additionally guarded by
# !defined(ANDROID). Matching the reference matters more here than tidiness.
#
# add_definitions() in gl4es's CMakeLists is a directory property, so the
# ANDROID it adds has to be removed at the directory it was set on rather than
# overridden on the target.
foreach(GL4ES_DIR ${gl4es_SOURCE_DIR} ${gl4es_SOURCE_DIR}/src)
set_property(DIRECTORY ${GL4ES_DIR} PROPERTY COMPILE_DEFINITIONS
BCMHOST
NOX11
NO_GBM
DEFAULT_ES=2
NO_INIT_CONSTRUCTOR)
endforeach()
target_compile_options(GL PRIVATE
-O3
# gl4es is old C that expects tentative definitions to be merged. Clang has
# defaulted to -fno-common since 15; the NDK here is newer than that and the
# reference build is not.
-fcommon
-fvisibility=hidden
-funwind-tables)
# What their LOCAL_LDLIBS names. gl4es dlopen()s the driver itself, so these are
# not needed to resolve anything; they are here so the library is built against
# the same set the reference links.
target_link_libraries(GL PRIVATE EGL GLESv3 dl log)
set_target_properties(GL PROPERTIES
# Shipped inside the APK, so it needs the prefix the top level CMakeLists
# cleared for the engine's own libraries.
OUTPUT_NAME gl4es
PREFIX ${ANDROID_APK_LIBRARY_PREFIX}
# gl4es builds itself as libGL.so.1 in the manner of a system OpenGL
# (src/CMakeLists.txt forces the suffix). Android has no versioned sonames:
# the package manager only extracts lib*.so out of an APK, and the linker
# would not load the result if it did. Back to a plain suffix.
SUFFIX ".so"
# gl4es's own CMakeLists points CMAKE_LIBRARY_OUTPUT_DIRECTORY at
# ${CMAKE_SOURCE_DIR}/lib, and under FetchContent that is *this* project's
# source root - so without this the artifact is written into the engine's
# source tree, where nobody thinks to look for it and git has to ignore it.
LIBRARY_OUTPUT_DIRECTORY ${gl4es_BINARY_DIR}/lib)
# The renderer includes <GL/gl.h>, which on Android only exists because gl4es
# provides it.
list(APPEND RENDERER_INCLUDE_DIRS ${gl4es_SOURCE_DIR}/include)
list(APPEND RENDERER_LIBRARIES GL)
list(APPEND RENDERER_DEFINITIONS USE_GL4ES)
+43 -1
View File
@@ -6,7 +6,49 @@ if(NOT BUILD_CLIENT)
return()
endif()
set(INTERNAL_OPENAL_DIR ${SOURCE_DIR}/thirdparty/openal-soft-1.24.3)
set(INTERNAL_OPENAL_VERSION 1.24.3)
set(INTERNAL_OPENAL_DIR ${SOURCE_DIR}/thirdparty/openal-soft-${INTERNAL_OPENAL_VERSION})
if(ANDROID)
# There is no system OpenAL on Android, and OpenAL is not optional here:
# it is the engine's only sound driver (see SOUND_DRIVER in
# snd_local_new.h). Build OpenAL Soft from source; its OpenSL ES backend is
# what actually reaches the headset.
include(FetchContent)
set(OPENAL_SOURCE_PATH "" CACHE PATH
"Existing OpenAL Soft source tree to build against; downloaded if empty")
if(OPENAL_SOURCE_PATH)
FetchContent_Declare(OpenAL SOURCE_DIR ${OPENAL_SOURCE_PATH})
else()
FetchContent_Declare(OpenAL
URL https://github.com/kcat/openal-soft/archive/refs/tags/${INTERNAL_OPENAL_VERSION}.tar.gz
URL_HASH SHA256=7e1fecdeb45e7f78722b776c5cf30bd33934b961d7fd2a11e0494e064cc631ce)
endif()
set(ALSOFT_EXAMPLES OFF CACHE BOOL "" FORCE)
set(ALSOFT_UTILS OFF CACHE BOOL "" FORCE)
set(ALSOFT_TESTS OFF CACHE BOOL "" FORCE)
set(ALSOFT_INSTALL OFF CACHE BOOL "" FORCE)
set(ALSOFT_INSTALL_CONFIG OFF CACHE BOOL "" FORCE)
set(ALSOFT_INSTALL_HRTF_DATA OFF CACHE BOOL "" FORCE)
set(ALSOFT_INSTALL_AMBDEC_PRESETS OFF CACHE BOOL "" FORCE)
set(ALSOFT_INSTALL_EXAMPLES OFF CACHE BOOL "" FORCE)
set(ALSOFT_INSTALL_UTILS OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(OpenAL)
# See the same note in sdl.cmake: the cleared prefix is inherited from the
# top level and has to be put back for anything shipped inside the APK.
set_target_properties(OpenAL PROPERTIES PREFIX ${ANDROID_APK_LIBRARY_PREFIX})
# OpenAL::OpenAL carries its own include directory, so <AL/al.h> resolves
# without the vendored copy.
list(APPEND CLIENT_DEFINITIONS USE_OPENAL)
list(APPEND CLIENT_LIBRARIES OpenAL::OpenAL)
return()
endif()
find_package(OpenAL QUIET)
+46
View File
@@ -0,0 +1,46 @@
if(NOT USE_OPENXR OR NOT BUILD_CLIENT)
return()
endif()
# The Khronos loader is the vendor-neutral one: it finds whatever runtime the
# device provides (Meta, Pico, SteamVR) rather than binding us to a vendor SDK.
include(FetchContent)
set(OPENXR_VERSION 1.1.43)
set(OPENXR_SOURCE_PATH "" CACHE PATH
"Existing OpenXR-SDK source tree to build against; downloaded if empty")
if(OPENXR_SOURCE_PATH)
FetchContent_Declare(OpenXR SOURCE_DIR ${OPENXR_SOURCE_PATH})
else()
FetchContent_Declare(OpenXR
URL https://github.com/KhronosGroup/OpenXR-SDK/archive/refs/tags/release-${OPENXR_VERSION}.tar.gz
URL_HASH SHA256=fd0834e4bc75c935248d82ac044f472ab3af4d8ff3b191a3ccace3436975a4cf)
endif()
set(BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(BUILD_API_LAYERS OFF CACHE BOOL "" FORCE)
set(BUILD_LOADER ON CACHE BOOL "" FORCE)
set(DYNAMIC_LOADER ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(OpenXR)
if(ANDROID)
# Shipped in the APK, so it needs the prefix the top level CMakeLists cleared
set_target_properties(openxr_loader PROPERTIES PREFIX ${ANDROID_APK_LIBRARY_PREFIX})
endif()
list(APPEND CLIENT_DEFINITIONS USE_OPENXR)
list(APPEND CLIENT_LIBRARIES OpenXR::openxr_loader)
list(APPEND CLIENT_LIBRARY_SOURCES ${SOURCE_DIR}/vr/vr_openxr.c)
if(ANDROID)
# The VR layer owns the eye framebuffers and hands the session its EGL
# context, so unlike the renderer it calls GL and EGL directly rather than
# through SDL's loader.
list(APPEND CLIENT_LIBRARIES EGL GLESv3)
endif()
# The renderer needs to know which framebuffer to draw an eye into
list(APPEND RENDERER_DEFINITIONS USE_OPENXR)
+42 -1
View File
@@ -9,10 +9,51 @@ if(EMSCRIPTEN)
return()
endif()
set(INTERNAL_SDL_DIR ${SOURCE_DIR}/thirdparty/SDL2-2.32.8)
set(INTERNAL_SDL_VERSION 2.32.8)
set(INTERNAL_SDL_DIR ${SOURCE_DIR}/thirdparty/SDL2-${INTERNAL_SDL_VERSION})
include(utils/arch)
if(ANDROID)
# There is no prebuilt SDL for Android, so it is built from source next to
# the engine. The same tree also carries the org.libsdl.app Java classes
# the APK needs, so the unpacked source is worth keeping hold of.
include(FetchContent)
set(SDL2_SOURCE_PATH "" CACHE PATH
"Existing SDL2 source tree to build against; downloaded if empty")
if(SDL2_SOURCE_PATH)
FetchContent_Declare(SDL2 SOURCE_DIR ${SDL2_SOURCE_PATH})
else()
FetchContent_Declare(SDL2
URL https://github.com/libsdl-org/SDL/releases/download/release-${INTERNAL_SDL_VERSION}/SDL2-${INTERNAL_SDL_VERSION}.tar.gz
URL_HASH SHA256=0ca83e9c9b31e18288c7ec811108e58bac1f1bb5ec6577ad386830eac51c787e)
endif()
# SDLActivity loads libSDL2.so from the APK by name, so it has to be shared.
set(SDL_SHARED ON CACHE BOOL "" FORCE)
set(SDL_STATIC OFF CACHE BOOL "" FORCE)
set(SDL_TEST OFF CACHE BOOL "" FORCE)
set(SDL2_DISABLE_INSTALL ON CACHE BOOL "" FORCE)
set(SDL2_DISABLE_UNINSTALL ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(SDL2)
# The top level CMakeLists clears the shared library prefix, which the
# fetched project inherits. Put it back, or the APK ships an SDL2.so the
# package manager will not extract and SDLActivity cannot load.
set_target_properties(SDL2 PROPERTIES PREFIX ${ANDROID_APK_LIBRARY_PREFIX})
# Where the APK build picks up SDLActivity and friends
set(SDL2_JAVA_DIR ${sdl2_SOURCE_DIR}/android-project/app/src/main/java
CACHE INTERNAL "")
list(APPEND CLIENT_LIBRARIES SDL2::SDL2)
list(APPEND RENDERER_LIBRARIES SDL2::SDL2)
return()
endif()
if(WIN32 OR APPLE)
# On Windows and macOS we have internal SDL binaries we can use
set(HAVE_INTERNAL_SDL true)
+1
View File
@@ -1,3 +1,4 @@
include(platforms/android)
include(platforms/emscripten)
include(platforms/linux)
include(platforms/macos)
+95
View File
@@ -0,0 +1,95 @@
# Android specific settings.
#
# Configured with the NDK's android.toolchain.cmake, e.g.
#
# cmake -B build-android \
# -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake \
# -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-29
#
# Nothing here is Quest specific; a phone build works the same way.
if(NOT ANDROID)
return()
endif()
# The renderer choice is left to the top level options, which default to
# renderergl1. That is the fixed function renderer this game's art was authored
# for, and USE_GL4ES puts the translation layer underneath it that lets it reach
# OpenGL ES; see cmake/libraries/gl4es.cmake for why it is worth the layer.
#
# renderergl2 speaks GLES natively and was the first thing to run here, but it
# spends tens of milliseconds of CPU per eye binding GLSL programs for lighting
# this content has no maps to feed. It is still reachable with
#
# -DBUILD_RENDERER_GL1=OFF -DBUILD_RENDERER_GL2=ON -DUSE_GL4ES=OFF
#
# Nothing may be forced into the cache here: these are declared with option(),
# so a `set(... CACHE INTERNAL "")` implies FORCE and would silently overwrite
# whatever was asked for on the command line.
# Android's linker namespaces are particular about where a dlopen'd library
# may live. Link the renderer into the client rather than fight them.
set(USE_RENDERER_DLOPEN OFF CACHE INTERNAL "")
set(USE_OPENAL_DLOPEN OFF CACHE INTERNAL "")
# The QVM toolchain is not part of an Android build.
set(BUILD_GAME_QVMS OFF CACHE INTERNAL "")
# Only for downloading content off a server; not needed to get the game up.
set(USE_HTTP OFF CACHE INTERNAL "")
# The whole point of the Android build here is the headset.
set(USE_OPENXR ON CACHE INTERNAL "")
# There is no bundled SDL binary for Android; it is built from source.
set(USE_INTERNAL_SDL OFF CACHE INTERNAL "")
# No shell to attach a console to, and no desktop to install into.
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/install CACHE PATH "" FORCE)
# Everything the engine prints ends up in logcat.
list(APPEND COMMON_LIBRARIES log)
# Android extracts only lib*.so out of an APK, so everything shipped inside one
# needs the prefix - but the top level CMakeLists clears it for the whole
# project. Targets bound for the APK opt back in individually with this.
set(ANDROID_APK_LIBRARY_PREFIX "lib")
# Gradle wants every native library for an ABI in one directory, so gather
# them there once everything has been declared.
list(APPEND POST_CONFIGURE_FUNCTIONS android_stage_apk_libraries)
function(android_stage_apk_libraries)
set(APK_LIB_DIR ${CMAKE_BINARY_DIR}/apk-libs/${ANDROID_ABI})
# Several of our libraries use the STL, so it is the shared one and has to
# be shipped alongside them.
set(APK_LIBRARIES ${CMAKE_SYSROOT}/usr/lib/${CMAKE_LIBRARY_ARCHITECTURE}/libc++_shared.so)
set(APK_TARGETS)
foreach(APK_TARGET IN ITEMS ${CLIENT_NAME} SDL2 OpenAL openxr_loader GL ${CGAME_MODULE} ${GAME_MODULE})
if(TARGET ${APK_TARGET})
list(APPEND APK_TARGETS ${APK_TARGET})
list(APPEND APK_LIBRARIES $<TARGET_FILE:${APK_TARGET}>)
endif()
endforeach()
if(NOT APK_TARGETS)
return()
endif()
add_custom_target(apk-libs ALL
COMMAND ${CMAKE_COMMAND} -E make_directory ${APK_LIB_DIR}
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${APK_LIBRARIES} ${APK_LIB_DIR}
COMMENT "Staging APK libraries in ${APK_LIB_DIR}"
VERBATIM)
add_dependencies(apk-libs ${APK_TARGETS})
# The APK needs the org.libsdl.app Java classes belonging to the very SDL
# the engine was linked against, so the gradle build reads the path from
# here rather than guessing.
file(WRITE ${CMAKE_BINARY_DIR}/android-build.properties
"# Written by the CMake build; consumed by misc/android\n"
"sdlJavaDir=${SDL2_JAVA_DIR}\n")
endfunction()
+1 -1
View File
@@ -1,6 +1,6 @@
# Linux-like specific settings, i.e. including FreeBSD etc.
if(NOT UNIX OR APPLE)
if(NOT UNIX OR APPLE OR ANDROID)
return()
endif()
+10 -2
View File
@@ -6,7 +6,10 @@ endif()
list(APPEND SYSTEM_PLATFORM_SOURCES ${SOURCE_DIR}/sys/sys_unix.c)
if(EMSCRIPTEN)
if(ANDROID)
# No tty; the console goes to logcat
list(APPEND SYSTEM_PLATFORM_SOURCES ${SOURCE_DIR}/sys/con_android.c)
elseif(EMSCRIPTEN)
list(APPEND SYSTEM_PLATFORM_SOURCES ${SOURCE_DIR}/sys/con_passive.c)
else()
list(APPEND SYSTEM_PLATFORM_SOURCES ${SOURCE_DIR}/sys/con_tty.c)
@@ -21,7 +24,12 @@ list(APPEND COMMON_LIBRARIES
m # Math library
)
list(APPEND SYSTEM_PLATFORM_SOURCES ${SOURCE_DIR}/sys/new/sys_unix_new.c)
if(ANDROID)
# bionic has no <execinfo.h>, so the backtrace is walked differently
list(APPEND SYSTEM_PLATFORM_SOURCES ${SOURCE_DIR}/sys/new/sys_android_new.c)
else()
list(APPEND SYSTEM_PLATFORM_SOURCES ${SOURCE_DIR}/sys/new/sys_unix_new.c)
endif()
find_package(Threads)
list(APPEND COMMON_LIBRARIES ${CMAKE_DL_LIBS} ${CMAKE_THREAD_LIBS_INIT})
+1
View File
@@ -37,6 +37,7 @@ set(RENDERER_GL1_SOURCES
${SOURCE_DIR}/renderergl1/tr_terrain.c
${SOURCE_DIR}/renderergl1/tr_util.cpp
${SOURCE_DIR}/renderergl1/tr_vis.cpp
${SOURCE_DIR}/renderergl1/tr_vr.c
${SOURCE_DIR}/renderergl1/tr_world.c
)
+207 -28
View File
@@ -683,8 +683,9 @@ void CG_InitializeObjectives()
cg.ObjectivesCurrentAlpha = 0.0;
for (i = 0; i < MAX_OBJECTIVES; i++) {
cg.Objectives[i].flags = 0;
cg.Objectives[i].text[0] = 0;
// A new gamestate may already contain objectives (for example after
// loading a save). Subsequent changes arrive through configstrings.
CG_ProcessConfigString(CS_OBJECTIVES + i, qfalse);
}
}
@@ -703,12 +704,9 @@ void CG_DrawObjectives()
float fTimeDelta;
const char *pszLocalizedText;
const char *pszLine;
float fAlpha;
iTotalNumLines = 0;
for (i = CS_OBJECTIVES; i < CS_OBJECTIVES + MAX_OBJECTIVES; ++i) {
CG_ProcessConfigString(i, qfalse);
}
iCurrentObjective = atoi(CG_ConfigString(CS_CURRENT_OBJECTIVE));
fTimeDelta = cg.ObjectivesAlphaTime - cg.time;
cg.ObjectivesCurrentAlpha = cg.ObjectivesDesiredAlpha;
@@ -718,7 +716,71 @@ void CG_DrawObjectives()
+ cg.ObjectivesDesiredAlpha;
}
if (cg.ObjectivesCurrentAlpha < 0.02) {
//
// Added in OPM
// Full on the wrist panel, whatever the fade says. In the flat game the
// objectives are a hold-the-key overlay - CG_ScoresDown_f fades them in
// while the key is held - and a transient overlay on a panel that is
// itself only visible while the player is looking at their wrist would
// almost never be seen at all. Turning your wrist up *is* the gesture
// that asks for them, so the fade has nothing left to do there.
//
// Held in a local rather than written back to cg.ObjectivesCurrentAlpha:
// CG_DrawStopwatch reads that field to decide the stopwatch is covered
// and removes it, and CG_SetDesiredObjectiveAlpha animates from it.
//
fAlpha = cg.ObjectivesCurrentAlpha;
//
// Added in OPM
// Always up in VR, whatever the fade says. In the flat game the objectives
// are a hold-the-key overlay - CG_ScoresDown_f fades them in while the key
// is held - and there is no key to hold in a headset.
//
// Its own switch rather than a test on the draw pass. It was written as
// pass == 1 while the HUD lived on the wrist panel; when the HUD moved
// back into the eye buffers the pass became 0 and the objectives went
// straight back to invisible, which took a device run to notice.
//
if (vr_hudPass && vr_hudPass->integer && vr_objectivesAlways && vr_objectivesAlways->integer) {
fAlpha = 1.0f;
}
//
// Added in OPM
// The objectives were reported missing from the wrist panel and there are
// three quite different reasons they could be, so this says which. The
// pass names where it is drawing; alpha under 0.02 is the fade still
// holding them off; and a set count of zero means the level never sent any
// - which a map entered straight through vr_startMap, past the scripting
// that hands them out, is a very good way to arrange.
//
if (vr_traceObjectives && vr_traceObjectives->integer) {
static int lastReport;
if (!lastReport || cg.time - lastReport > 2000) {
int iSet = 0;
int k;
for (k = 0; k < MAX_OBJECTIVES; k++) {
if (cg.Objectives[k].flags != OBJ_FLAG_NONE && !(cg.Objectives[k].flags & OBJ_FLAG_HIDDEN)) {
iSet++;
}
}
lastReport = cg.time;
cgi.Printf(
"objectives: pass %d alpha %.2f (raw %.2f) set %d current %d\n",
vr_hudPass ? vr_hudPass->integer : -1,
fAlpha,
cg.ObjectivesCurrentAlpha,
iSet,
iCurrentObjective
);
}
}
if (fAlpha < 0.02) {
return;
}
@@ -749,7 +811,7 @@ void CG_DrawObjectives()
vColor[2] = 0.2f;
vColor[1] = 0.2f;
vColor[0] = 0.2f;
vColor[3] = cg.ObjectivesCurrentAlpha * 0.75;
vColor[3] = fAlpha * 0.75;
cgi.R_SetColor(vColor);
cgi.R_DrawStretchPic(
fX,
@@ -768,7 +830,7 @@ void CG_DrawObjectives()
vColor[0] = 1.0;
vColor[1] = 1.0;
vColor[2] = 1.0;
vColor[3] = cg.ObjectivesCurrentAlpha;
vColor[3] = fAlpha;
cgi.R_SetColor(vColor);
cgi.R_DrawString(
cgs.media.objectiveFont,
@@ -801,26 +863,26 @@ void CG_DrawObjectives()
vColor[0] = 1.0;
vColor[1] = 1.0;
vColor[2] = 1.0;
vColor[3] = cg.ObjectivesCurrentAlpha;
vColor[3] = fAlpha;
hBoxShader = cgs.media.uncheckedBoxShader;
} else if ((cg.Objectives[i].flags & OBJ_FLAG_COMPLETED) != 0) {
vColor[0] = 0.75;
vColor[1] = 0.75;
vColor[2] = 0.75;
vColor[3] = cg.ObjectivesCurrentAlpha;
vColor[3] = fAlpha;
hBoxShader = cgs.media.checkedBoxShader;
} else {
vColor[0] = 1.0;
vColor[1] = 1.0;
vColor[2] = 1.0;
vColor[3] = cg.ObjectivesCurrentAlpha;
vColor[3] = fAlpha;
hBoxShader = cgs.media.uncheckedBoxShader;
}
if (i == iCurrentObjective && !(cg.Objectives[i].flags & OBJ_FLAG_COMPLETED)) {
vColor[0] = 1.0;
vColor[1] = 1.0;
vColor[2] = 0.0;
vColor[3] = cg.ObjectivesCurrentAlpha;
vColor[3] = fAlpha;
}
cgi.R_SetColor(vColor);
@@ -840,7 +902,7 @@ void CG_DrawObjectives()
vColor[0] = 1.0;
vColor[1] = 1.0;
vColor[2] = 1.0;
vColor[3] = cg.ObjectivesCurrentAlpha;
vColor[3] = fAlpha;
cgi.R_SetColor(vColor);
cgi.R_DrawStretchPic(
fX * cgs.uiHiResScale[0],
@@ -1524,20 +1586,137 @@ void CG_DrawVote()
CG_Draw2D
==============
*/
/*
=================
CG_DrawVRWheel
The weapon wheel, while the player is holding it open.
Deliberately plain: names in a ring with the one under the controller lit up.
A wheel is only worth having if it can be read at a glance while something is
shooting at you, and the thing that makes it readable is the highlight moving
the moment the wrist does - not what the segments are made of.
Drawn with the world rather than on the wrist panel, because the hand aiming it
is out in front of the player and a menu that lives somewhere else would have
them looking in two places at once.
=================
*/
static void CG_DrawVRWheel(void)
{
const char *names[8];
int selection = -1;
int count = 0;
int i;
float cx, cy, radius;
vec2_t textScale;
if (!cgi.VR_GetWheel || !cgi.VR_GetWheel(&selection, names, (int)ARRAY_LEN(names), &count)) {
return;
}
if (count <= 0 || !cgs.media.objectiveFont) {
return;
}
// Lay out the ring in eye pixels, then undo the text scale on both axes.
// Mixing virtual x with pixel y placed it at y=240 in a 1760-line eye.
textScale[0] = textScale[1] = cgs.glconfig.vidHeight / 1080.0f;
if (textScale[0] <= 0.0f) {
return;
}
cx = cgs.glconfig.vidWidth * 0.5f;
cy = cgs.glconfig.vidHeight * 0.5f;
radius = 120.0f * textScale[0];
if (count > (int)ARRAY_LEN(names)) {
count = (int)ARRAY_LEN(names);
}
for (i = 0; i < count; i++) {
// Clockwise from straight up, matching the order the VR layer numbers
// the segments in - the two have to agree or the highlight lands on
// the wrong word.
const float angle = (float)i * (2.0f * M_PI / (float)count);
const float width = cgi.UI_FontStringWidth(cgs.media.objectiveFont, names[i], -1) * textScale[0];
const float x = cx + sinf(angle) * radius - width * 0.5f;
const float y = cy - cosf(angle) * radius;
vec4_t colour;
if (i == selection) {
colour[0] = 1.0f; colour[1] = 0.85f; colour[2] = 0.4f; colour[3] = 1.0f;
} else {
colour[0] = 0.75f; colour[1] = 0.75f; colour[2] = 0.75f; colour[3] = 0.6f;
}
cgi.R_SetColor(colour);
cgi.R_DrawString(cgs.media.objectiveFont, names[i], x / textScale[0], y / textScale[1], -1, textScale);
}
cgi.R_SetColor(NULL);
}
void CG_Draw2D(void)
{
CG_UpdateCountdown();
CG_DrawZoomOverlay();
CG_DrawLagometer();
CG_HudDrawElements();
CG_DrawObjectives();
CG_DrawIcons();
CG_DrawStopwatch();
CG_DrawSpectatorView();
CG_DrawPlayerTeam();
CG_DrawPlayerEntInfo();
CG_UpdateAttackerDisplay();
CG_DrawVote();
CG_DrawInstantMessageMenu();
CG_DrawCrosshair();
//
// Added in OPM
// 0 is the flat frame, 1 the wrist panel, 2 the world without its HUD,
// and 3 a VR eye carrying both the world and the HUD.
//
const int pass = vr_hudPass ? vr_hudPass->integer : 0;
if (pass) {
// Keep damage feedback current even when the wrist panel is hidden.
CG_UpdateVRHud();
}
if (pass != 2) {
CG_UpdateCountdown();
CG_DrawLagometer();
CG_HudDrawElements();
//
// Added in OPM
// The gathered VR HUD carries the objectives itself, laid out beside
// the compass rather than across the top left of a screen nobody in a
// headset is looking at. Drawing both would put the same text on the
// view twice, in two places, at two sizes.
//
if (pass && vr_hud && vr_hud->integer) {
CG_DrawVRHud();
} else {
CG_DrawObjectives();
}
CG_DrawIcons();
CG_DrawStopwatch();
CG_DrawSpectatorView();
CG_DrawPlayerTeam();
CG_DrawPlayerEntInfo();
CG_UpdateAttackerDisplay();
CG_DrawVote();
CG_DrawInstantMessageMenu();
}
if (pass != 1) {
CG_DrawVRWheel();
// The scope surround has to sit around the magnified view, so it stays
// with the world rather than travelling to the wrist.
CG_DrawZoomOverlay();
//
// Added in OPM
// The reticle is drawn at the centre of the screen, which in a headset
// is the centre of the player's head - and the weapon is aimed by the
// controller, not by where they are looking. So it marks a spot the
// gun is not pointing at, pinned to their face wherever they turn.
//
// pass 0 is the flat frame, where the reticle means what it always
// meant and is drawn as before. vr_crosshair brings it back in VR for
// anyone who wants it.
//
if (!pass || (vr_crosshair && vr_crosshair->integer)) {
CG_DrawCrosshair();
}
}
}
+20
View File
@@ -513,6 +513,19 @@ extern "C" {
//
extern cvar_t *cg_fov;
extern cvar_t *cg_cheats;
// Which half of a split VR frame is being drawn; see CG_Draw2D.
extern cvar_t *vr_hudPass;
extern cvar_t *vr_crosshair;
extern cvar_t *vr_traceObjectives;
extern cvar_t *vr_objectivesAlways;
// The gathered VR HUD; see cg_vrhud.cpp.
extern cvar_t *vr_hud;
extern cvar_t *vr_hudGroupX;
extern cvar_t *vr_hudGroupY;
extern cvar_t *vr_hudGroupScale;
extern cvar_t *vr_hudAlpha;
extern cvar_t *vr_hudTime;
extern cvar_t *vr_hudShow;
//
// cg_main.c
@@ -600,6 +613,13 @@ extern "C" {
void CG_ZoomDown_f(void);
void CG_ZoomUp_f(void);
//
// cg_vrhud.cpp
//
void CG_InitVRHud(void);
void CG_UpdateVRHud(void);
void CG_DrawVRHud(void);
void CG_EyePosition(vec3_t *o_vPos);
void CG_EyeOffset(vec3_t *o_vOfs);
void CG_EyeAngles(vec3_t *o_vAngles);
+47
View File
@@ -116,6 +116,25 @@ cvar_t *ui_timemessage;
// Added in OPM
//
cvar_t *cg_fov;
cvar_t *vr_hudPass;
// Added in OPM
// Whether the reticle is drawn in VR. Off: it sits at the centre of the head
// while the weapon is aimed by the controller, so it marks a spot the gun is
// not pointing at. See CG_Draw2D.
cvar_t *vr_crosshair;
cvar_t *vr_traceObjectives;
cvar_t *vr_objectivesAlways;
// Added in OPM
// The gathered VR HUD - compass, objectives, health, ammo and the weapon
// strip drawn as one group in the middle of the view instead of as five
// widgets in five corners of a lens. See cg_vrhud.cpp.
cvar_t *vr_hud;
cvar_t *vr_hudGroupX;
cvar_t *vr_hudGroupY;
cvar_t *vr_hudGroupScale;
cvar_t *vr_hudAlpha;
cvar_t *vr_hudTime;
cvar_t *vr_hudShow;
cvar_t *cg_cheats;
/*
@@ -212,6 +231,32 @@ void CG_RegisterCvars(void)
//
cg_fov = cgi.Cvar_Get("cg_fov", "80", CVAR_ARCHIVE);
// 0 flat, 1 wrist panel, 2 world only, 3 combined VR world and HUD.
vr_hudPass = cgi.Cvar_Get("vr_hudPass", "0", 0);
vr_crosshair = cgi.Cvar_Get("vr_crosshair", "0", CVAR_ARCHIVE);
vr_traceObjectives = cgi.Cvar_Get("vr_traceObjectives", "0", 0);
vr_objectivesAlways = cgi.Cvar_Get("vr_objectivesAlways", "1", CVAR_ARCHIVE);
// Archived, and placed as fractions of the display rather than in pixels,
// so the cluster can be moved and sized from autoexec.cfg. There is no
// console in a headset and this is a thing only judged while wearing one.
// 0 off, 1 up on the off hand's X button for vr_hudTime seconds, 2 always.
vr_hud = cgi.Cvar_Get("vr_hud", "1", CVAR_ARCHIVE);
vr_hudTime = cgi.Cvar_Get("vr_hudTime", "3", CVAR_ARCHIVE);
// Written by the VR layer when the button goes down; read here. Not
// archived - a timestamp from the last run means nothing to this one.
vr_hudShow = cgi.Cvar_Get("vr_hudShow", "0", 0);
vr_hudGroupX = cgi.Cvar_Get("vr_hudGroupX", "0.5", CVAR_ARCHIVE);
// Not where it looks like it is. This places the cluster in the flat frame,
// and the renderer then scales that frame to vr_hudScale about the middle
// of the display and lifts it by the eye's vertical frustum asymmetry - on
// a Quest 3, 170 pixels of 1760. Both of those pull the number back towards
// the middle of the view, so the range that reaches the eye at all is about
// 0.13 to 0.68 of it: 0.62 lands just above the middle, 0.73 just below,
// and 0.92 - this - well down at 0.63, which is where it wants to be for
// something checked without breaking aim.
vr_hudGroupY = cgi.Cvar_Get("vr_hudGroupY", "0.92", CVAR_ARCHIVE);
vr_hudGroupScale = cgi.Cvar_Get("vr_hudGroupScale", "1.0", CVAR_ARCHIVE);
vr_hudAlpha = cgi.Cvar_Get("vr_hudAlpha", "1.0", CVAR_ARCHIVE);
cg_cheats = cgi.Cvar_Get("cheats", "0", CVAR_USERINFO | CVAR_SERVERINFO | CVAR_LATCH);
}
/*
@@ -667,6 +712,7 @@ void CG_GameStateReceived(void)
CG_InitializeSpecialEffectsManager();
CG_InitializeObjectives();
CG_InitVRHud();
}
/*
@@ -698,6 +744,7 @@ void CG_ServerRestarted(void)
CG_ResetVSSSources();
// Reset objectives
CG_InitializeObjectives();
CG_InitVRHud();
}
/*
+588 -5
View File
@@ -983,6 +983,480 @@ void CG_ProcessPlayerModel()
cg_forceModel->modified = qfalse;
}
/*
===============
CG_VRHandTag
The bone or tag a hand's mesh hangs from, so the model can be moved to put
*that* where the controller is rather than the model origin.
The two hands want different anchors. The off hand wants its bone, because the
hand itself is the thing being placed. The weapon hand wants tag_weapon_right,
because what the player is holding is the gun: the controller is a grip, and
putting the weapon's own attachment point on it is what makes the gun sit in the
hand rather than near it.
Every candidate is measured, not just the one taken. Whether an anchor is *at*
the hand is the whole question and its tag number does not answer it - a
distance of nearly nothing from the model origin means the tag is unused,
however successfully it resolved. tag_weapon_left is exactly that trap on this
model: it resolves, and it anchors nothing, because the first person model never
carries a weapon in its left hand.
===============
*/
static int CG_VRHandTag(refEntity_t *model, int hand)
{
static const char *const offHand[] = {"Bip01 L Hand", "tag_weapon_left"};
static const char *const weapHand[] = {"tag_weapon_right", "Bip01 R Hand"};
static qboolean reported[2];
const char *const *candidates = hand ? weapHand : offHand;
int chosen = -1;
int i;
//
// Says why, rather than returning -1 in silence. A silent -1 anchors the
// model on its own origin, which looks exactly like an anchor that was
// found and happened to be at the origin - and this cost a whole round trip
// once already, when the weapon hand printed nothing at all and there was
// no way to tell "never called" from "returned early" from "found nothing".
//
if (!model || !model->tiki || !cgi.Tag_NumForName || hand < 0 || hand > 1) {
if (hand >= 0 && hand <= 1 && !reported[hand]) {
cgi.Printf("VR: %s anchor unavailable (model %s, tiki %s, Tag_NumForName %s)\n",
hand ? "weapon hand" : "off hand",
model ? "ok" : "null",
(model && model->tiki) ? "ok" : "null",
cgi.Tag_NumForName ? "ok" : "null");
reported[hand] = qtrue;
}
return -1;
}
for (i = 0; i < 2; i++) {
const int tagNum = cgi.Tag_NumForName(model->tiki, candidates[i]);
if (!reported[hand] && cgi.TIKI_Orientation) {
if (tagNum < 0) {
cgi.Printf("VR: %s candidate \"%s\": absent\n",
hand ? "weapon hand" : "off hand", candidates[i]);
} else {
const orientation_t o = cgi.TIKI_Orientation(model, tagNum);
cgi.Printf("VR: %s candidate \"%s\": tag %d at %.1f %.1f %.1f (%.1f units out)\n",
hand ? "weapon hand" : "off hand", candidates[i], tagNum,
o.origin[0], o.origin[1], o.origin[2], VectorLength(o.origin));
}
}
if (tagNum >= 0 && chosen < 0) {
chosen = tagNum;
}
}
if (!reported[hand]) {
if (chosen < 0) {
cgi.Printf("VR: no %s anchor on this model; using the model origin instead\n",
hand ? "weapon hand" : "off hand");
}
reported[hand] = qtrue;
}
return chosen;
}
/*
===============
CG_VRAnchorOnTag
Takes a model whose origin and axes describe where the *hand* should be, and
rewrites them so it is the named tag that ends up there instead.
The engine composes a tag into the world as (entity.cpp, GetTagPositionAndOrientation):
world.origin = model.origin + tag.origin through model.axis
world.axis = tag.axis composed with model.axis
so this is that, inverted. An orientation's axes are orthonormal, so undoing the
tag's rotation is a transpose.
A tagNum of -1 leaves the model where it is, which anchors it on its own origin -
wrong by however far the animation puts the hand out in front, but visible and
debuggable rather than absent.
===============
*/
static void CG_VRAnchorOnTag(refEntity_t *model, int tagNum)
{
orientation_t tag;
vec3_t handOrigin, handAxis[3];
vec3_t inverse[3];
int i, j;
if (tagNum < 0 || !cgi.TIKI_Orientation) {
return;
}
VectorCopy(model->origin, handOrigin);
AxisCopy(model->axis, handAxis);
tag = cgi.TIKI_Orientation(model, tagNum);
for (i = 0; i < 3; i++) {
for (j = 0; j < 3; j++) {
inverse[i][j] = tag.axis[j][i];
}
}
MatrixMultiply(inverse, handAxis, model->axis);
VectorCopy(handOrigin, model->origin);
for (i = 0; i < 3; i++) {
VectorMA(model->origin, -tag.origin[i], model->axis[i], model->origin);
}
}
/*
===============
CG_VRHandsTracked
Whether there is a headset with a tracked weapon hand, asked before deciding
what the first person model is for.
The surface selection happens well before CG_VRPlaceViewModel is reached, and
it has to know then whether these are a pair of tracked hands or a flat view
model - the flat game hides the arms when the player has no weapon, and in VR
that is the one case where they should stay.
===============
*/
static qboolean CG_VRHandsTracked(void)
{
vec3_t trackingOffset, angles;
float baseYaw = 0.0f;
return (cgi.VR_GetWeaponPose && cgi.VR_GetWeaponPose(trackingOffset, angles, &baseYaw)) ? qtrue : qfalse;
}
/*
===============
CG_VRPlaceViewModel
Puts the first person model where the player's hand is.
The offset arrives relative to the head, in engine units, already turned into
the engine's frame and de-rotated by whatever heading the play space happened
to have. Adding it to the view origin puts the weapon in the room where the
hand is.
The height needs one more step. The view origin is at the eyes, so adding a
hand-relative-to-head offset to it would hang the weapon off the *camera*, and
crouching or looking down would drag it about. The reference drops to the feet
and adds the measured head height back (cg_weapons.c: origin[2] -= 64, then
+= hmdposition[1] * worldScale), which leaves the weapon at the height the
player is really holding their hand at. 64 is Quake's eye height above the
feet, and MOHAA inherits it.
Yaw is rebased the same way the aim is: the controller's absolute yaw is
meaningless to the game, so what arrives is how far the hand leads the head and
the view yaw is added back here.
Returns false when there is no headset, and the caller falls back to the flat
placement.
===============
*/
static qboolean CG_VRPlaceViewModel(refEntity_t *model)
{
vec3_t trackingOffset, angles;
float baseYaw = 0.0f;
vec3_t bodyAngles, bodyAxis[3];
int tagNum;
int i;
if (!cgi.VR_GetWeaponPose || !cgi.VR_GetWeaponPose(trackingOffset, angles, &baseYaw)) {
return qfalse;
}
//
// Composed exactly the way R_VRComposeView composes the camera, for the
// reason set out at length in CG_VRPlaceOffHandModel: cg.refdef.vieworg is
// not the camera, and anything anchored on it alone is left behind by the
// head's offset within the play space.
//
// This path used to add a head-relative offset to vieworg and then correct
// the height by dropping to the feet and adding the measured head height
// back. That is RTCWQuest's way of doing without this composition, and it
// has two failure modes this does not: the weapon slid whenever the head
// turned, and a snap turn left it behind entirely, because a stick turn
// moves the body's frame and a head-relative offset knows nothing about it.
//
VectorSet(bodyAngles, 0.0f, cg.refdefViewAngles[YAW] - baseYaw, 0.0f);
AnglesToAxis(bodyAngles, bodyAxis);
VectorCopy(cg.refdef.vieworg, model->origin);
for (i = 0; i < 3; i++) {
VectorMA(model->origin, trackingOffset[i], bodyAxis[i], model->origin);
}
angles[YAW] = AngleNormalize360(angles[YAW] + cg.refdefViewAngles[YAW]);
//
// Per weapon alignment.
//
// The view model is authored to sit at the camera with the arms reaching
// forward, so anchoring its origin at the hand leaves the model's own
// internal offset in place and the gun ends up somewhere near the elbow.
// Every weapon needs its own correction, and no amount of getting the hand
// pose right removes the need for one.
//
// Same shape as the reference's vr_weapon_adjustment_<id>
// (rtcw cg_weapons.c, VrInputWeaponAlign.c):
//
// scale,right,up,forward,pitch,yaw,roll
//
// The rotation is composed in the weapon's own frame rather than added to
// the angles, which is only correct for one axis at a time. The offset is
// then applied along the *corrected* axes - right, up and forward as the
// weapon now points - which is why a model that rotates correctly can still
// sit in the wrong place: the two have to be done in that order.
//
{
//
// The roll defaults to 180 because that is what the model needs, not
// because it is a nice starting point. Anchoring forces the *bone* to
// take the controller's orientation, and a wrist bone's local axes do
// not run the way a controller is held - the mesh inherits that
// mismatch as a constant half turn about the aim axis, which reads as
// the gun being upside down.
//
// It lives here rather than only in misc/android/autoexec.cfg because
// that file is not packaged into the APK - it goes next to the game
// data. A default of zero here means anyone who installs the build
// without also pushing that config gets the wrong answer.
//
cvar_t *adjust = cgi.Cvar_Get("vr_weaponAdjust", "1,0,0,0,0,0,180", CVAR_ARCHIVE);
vec3_t off = {0.0f, 0.0f, 0.0f};
vec3_t adjustAng = {0.0f, 0.0f, 0.0f};
float adjustScale = 1.0f;
vec3_t baseAxis[3], adjAxis[3];
if (adjust && adjust->string[0]) {
sscanf(adjust->string, "%f,%f,%f,%f,%f,%f,%f",
&adjustScale, &off[0], &off[1], &off[2],
&adjustAng[PITCH], &adjustAng[YAW], &adjustAng[ROLL]);
}
//
// The first field is the model's scale, not just the offset's. That is
// what it is called and what the reference does with it
// (VrInputWeaponAlign.c writes it into test_scale, and cg_weapons.c
// returns it as the entity scale) - here it was only ever scaling the
// offset, which made it a knob that appeared to do nothing to a model
// that was the wrong size.
//
// Applied before anything asks for a tag, because TIKI_Orientation
// multiplies the tag's origin by the entity scale. Anchoring first and
// scaling afterwards would move the hand off the controller by however
// much the scale changed.
//
if (adjustScale > 0.0f) {
model->scale *= adjustScale;
}
VectorScale(off, adjustScale, off);
AnglesToAxis(angles, baseAxis);
AnglesToAxis(adjustAng, adjAxis);
MatrixMultiply(adjAxis, baseAxis, model->axis);
// AnglesToAxis gives forward, left, up - so right is -axis[1].
VectorMA(model->origin, off[2], model->axis[0], model->origin);
VectorMA(model->origin, off[1], model->axis[2], model->origin);
VectorMA(model->origin, -off[0], model->axis[1], model->origin);
}
//
// model->origin and model->axis now hold where the *hand* should be. Turn
// that into where the model should be, so the grip lands in the hand rather
// than the model origin doing - the same inversion the off hand uses, and
// the reason it stopped needing a large vr_offHandAdjust.
//
// tag_weapon_right is preferred over the bone here because it is where the
// weapon is actually attached: anchoring it on the controller puts the
// gun's grip in the player's hand, which is the thing being held.
//
tagNum = CG_VRHandTag(model, 1);
CG_VRAnchorOnTag(model, tagNum);
return qtrue;
}
/*
===============
CG_VRPlaceOffHandModel
Puts a second copy of the view model where the *other* controller is, so the
hand that is not holding the weapon follows its own controller.
The pose arrives the same way the weapon hand's does and is rebased the same
way - see CG_VRPlaceViewModel for why the height and the yaw both need work.
What is different is the anchor. The weapon hand gets away with hanging the
model off its origin because vr_weaponAdjust then corrects for wherever the
weapon sits within the model. The off hand cannot: the animation puts that hand
a long way out in front of the model origin, and by a distance that changes with
the weapon. So the hand's own bone is asked where it is *within* the model and
that is taken back off, which lands the mesh on the controller whatever the
animation is doing.
tagWorld = model.origin + tagLocal.origin through model.axis
tagWorld.axis = tagLocal.axis composed with model.axis
so to put the tag at the hand, invert both: the model's axes become the hand's
with the bone's own rotation taken off, and its origin the hand's position with
the bone's offset taken back off along those axes.
vr_offHandAdjust is the residual, in the same seven-field form as
vr_weaponAdjust, because a bone is not a wrist and the difference has to be
found on the device.
Returns false when there is no headset or that hand is not tracked, and the
caller does not draw the off hand at all.
===============
*/
static qboolean CG_VRPlaceOffHandModel(refEntity_t *model)
{
vec3_t trackingOffset, angles;
float baseYaw = 0.0f;
vec3_t handOrigin, handAxis[3];
vec3_t bodyAngles, bodyAxis[3];
int tagNum;
int i;
if (!cgi.VR_GetHandPose || !cgi.VR_GetHandPose(0, trackingOffset, angles, &baseYaw)) {
return qfalse;
}
//
// Composed exactly the way R_VRComposeView composes the camera
// (renderergl1/tr_vr.c), because the hand has to land in the same space the
// camera ends up in.
//
// The renderer does not use cg.refdef.vieworg as it stands: it adds the
// head's play space offset to it, rotated into the body's frame. So
// anchoring anything on cg.refdef.vieworg alone leaves it behind by exactly
// that offset - and because the head swings about the neck, every look
// around moves it. That was the "hands drift away when you turn your head".
//
// The body's frame is the view yaw with the head's own contribution taken
// back off, which is what baseYaw carries.
//
VectorSet(bodyAngles, 0.0f, cg.refdefViewAngles[YAW] - baseYaw, 0.0f);
AnglesToAxis(bodyAngles, bodyAxis);
VectorCopy(cg.refdef.vieworg, handOrigin);
for (i = 0; i < 3; i++) {
VectorMA(handOrigin, trackingOffset[i], bodyAxis[i], handOrigin);
}
//
// No eye-height fudge here. Dropping to the feet and adding the measured
// head height back is how the weapon hand gets by without this composition;
// once the hand is placed in the play space properly its height is already
// right, and doing both would move it twice.
//
angles[YAW] = AngleNormalize360(angles[YAW] + cg.refdefViewAngles[YAW]);
{
//
// 180 for the same reason as the weapon hand, and measured to be the
// same number despite the two hands being mirrored - which was not a
// given, and is why this is its own cvar rather than a shared one.
//
cvar_t *adjust = cgi.Cvar_Get("vr_offHandAdjust", "1,0,0,0,0,0,180", CVAR_ARCHIVE);
vec3_t off = {0.0f, 0.0f, 0.0f};
vec3_t adjustAng = {0.0f, 0.0f, 0.0f};
float adjustScale = 1.0f;
vec3_t baseAxis[3], adjAxis[3];
if (adjust && adjust->string[0]) {
sscanf(adjust->string, "%f,%f,%f,%f,%f,%f,%f",
&adjustScale, &off[0], &off[1], &off[2],
&adjustAng[PITCH], &adjustAng[YAW], &adjustAng[ROLL]);
}
//
// The first field is the model's scale, not just the offset's. That is
// what it is called and what the reference does with it
// (VrInputWeaponAlign.c writes it into test_scale, and cg_weapons.c
// returns it as the entity scale) - here it was only ever scaling the
// offset, which made it a knob that appeared to do nothing to a model
// that was the wrong size.
//
// Applied before anything asks for a tag, because TIKI_Orientation
// multiplies the tag's origin by the entity scale. Anchoring first and
// scaling afterwards would move the hand off the controller by however
// much the scale changed.
//
if (adjustScale > 0.0f) {
model->scale *= adjustScale;
}
VectorScale(off, adjustScale, off);
AnglesToAxis(angles, baseAxis);
AnglesToAxis(adjustAng, adjAxis);
MatrixMultiply(adjAxis, baseAxis, handAxis);
// AnglesToAxis gives forward, left, up - so right is -axis[1].
VectorMA(handOrigin, off[2], handAxis[0], handOrigin);
VectorMA(handOrigin, off[1], handAxis[2], handOrigin);
VectorMA(handOrigin, -off[0], handAxis[1], handOrigin);
}
VectorCopy(handOrigin, model->origin);
AxisCopy(handAxis, model->axis);
tagNum = CG_VRHandTag(model, 0);
CG_VRAnchorOnTag(model, tagNum);
return qtrue;
}
/*
===============
CG_VRShowOnlySurface
Leaves one surface of the view model drawn and hides the rest.
The first person model is four surfaces - triggerhand, lefthand, garandhand and
viewsleeves - so a hand can be drawn on its own without any mesh work. Passing
NULL hides all four, which is how the weapon hand's copy is told to stop drawing
the hand that has gone to the other controller.
viewsleeves is deliberately not separable: it is one mesh covering both
forearms, so it stays with whichever copy asks for it rather than being split.
===============
*/
static void CG_VRShowOnlySurface(refEntity_t *model, const char *keep)
{
static const char *const parts[] = {"triggerhand", "lefthand", "garandhand", "viewsleeves"};
int i;
for (i = 0; i < (int)(sizeof(parts) / sizeof(parts[0])); i++) {
const int surfaceNum = cgi.Surface_NameToNum(model->tiki, parts[i]);
if (surfaceNum < 0) {
continue;
}
if (keep && !Q_stricmp(parts[i], keep)) {
model->surfaces[surfaceNum] &= ~MDL_SURFACE_NODRAW;
} else {
model->surfaces[surfaceNum] |= MDL_SURFACE_NODRAW;
}
}
}
/*
===============
CG_ModelAnim
@@ -998,6 +1472,15 @@ void CG_ModelAnim(centity_t *cent, qboolean bDoShaderTime)
const char *szTagName;
int iAnimFlags;
qboolean bThirdPerson = qfalse;
//
// Added in OPM
//
// Set when the view model went to the weapon hand's controller, and to
// whichever of the two left hand surfaces the weapon called for. Together
// they say a second copy should follow the other controller.
//
qboolean bVRHandTracked = qfalse;
const char *pszVROffHandSurf = NULL;
s1 = &cent->currentState;
@@ -1448,9 +1931,43 @@ void CG_ModelAnim(centity_t *cent, qboolean bDoShaderTime)
if ((cent->currentState.eFlags & EF_UNARMED) || cg_drawviewmodel->integer <= 1
|| cg.snap->ps.stats[STAT_INZOOM] || cg.snap->ps.stats[STAT_HEALTH] <= 0) {
// unarmed or zooming, hide the arms
for (i = 0; i < MAX_MODEL_SURFACES; i++) {
model.surfaces[i] |= MDL_SURFACE_NODRAW;
//
// Added in OPM
//
// Being unarmed is not a reason to have no hands in VR. Flat,
// this model exists only to hold a weapon, so putting the gun
// away hides the arms with it and nobody notices. In a headset
// the player is holding two controllers whether or not there
// is anything in them, and hands that vanish when the weapon
// does read as the tracking having dropped out.
//
// No mesh work is needed for this: the bare hands are already
// separate surfaces - triggerhand is the right, lefthand the
// left. Only garandhand goes, being a left hand posed around a
// rifle that is not there.
//
// The other three reasons to hide still hide. Zoom and death
// are not about whether a weapon is held, and
// cg_drawviewmodel is the player asking for no view model at
// all - none of those should be overridden here.
//
if ((cent->currentState.eFlags & EF_UNARMED) && cg_drawviewmodel->integer > 1
&& !cg.snap->ps.stats[STAT_INZOOM] && cg.snap->ps.stats[STAT_HEALTH] > 0
&& CG_VRHandsTracked()) {
const int iGarandHand = cgi.Surface_NameToNum(model.tiki, "garandhand");
if (iGarandHand >= 0) {
model.surfaces[iGarandHand] |= MDL_SURFACE_NODRAW;
}
// Which sends the left hand to its own controller below,
// exactly as it does when a weapon is held.
pszVROffHandSurf = "lefthand";
} else {
// unarmed or zooming, hide the arms
for (i = 0; i < MAX_MODEL_SURFACES; i++) {
model.surfaces[i] |= MDL_SURFACE_NODRAW;
}
}
} else {
// show/hide the garand hand depending if it's a rifle or not
@@ -1476,6 +1993,8 @@ void CG_ModelAnim(centity_t *cent, qboolean bDoShaderTime)
if (iSurfaceNum >= 0) {
model.surfaces[iSurfaceNum] &= ~MDL_SURFACE_NODRAW;
}
pszVROffHandSurf = "garandhand";
} else {
// hide the garand hands
@@ -1488,6 +2007,8 @@ void CG_ModelAnim(centity_t *cent, qboolean bDoShaderTime)
if (iSurfaceNum >= 0) {
model.surfaces[iSurfaceNum] &= ~MDL_SURFACE_NODRAW;
}
pszVROffHandSurf = "lefthand";
}
}
@@ -1497,8 +2018,50 @@ void CG_ModelAnim(centity_t *cent, qboolean bDoShaderTime)
}
if (!(cg.predicted_player_state.pm_flags & PMF_CAMERA_VIEW)) {
if (cg.snap->ps.stats[STAT_HEALTH] > 0 && !cg_animationviewmodel->integer) {
CG_OffsetFirstPersonView(&model, qfalse);
//
// Added in OPM
//
// In VR the weapon belongs in the player's hand, not bolted to
// the middle of the screen. CG_OffsetFirstPersonView places it
// against the camera and adds the walk bob and the landing dip,
// all of which are wrong here: the hand has its own position,
// and shaking the view model when the camera is the player's
// head is a way to make people ill.
//
// This is RTCWQuest's CG_CalculateVRWeaponPosition
// (rtcw/src/cgame/cg_weapons.c), which does the same three
// things: put the model at the hand's offset from the head,
// re-base its height so it hangs at the player's real hand
// height rather than at eye level, and rebase its yaw by how
// far the hand leads the head.
//
if (CG_VRPlaceViewModel(&model)) {
//
// This copy is the weapon hand and nothing else.
//
// The other hand comes off it and is drawn again below on
// its own controller. The sleeves come off too, which is
// the part that is not obvious: viewsleeves is a single
// mesh carrying *both* forearms - weighted 27% to
// Bip01 R Forearm and 27% to Bip01 L Forearm, with both
// elbows, shoulders and clavicles - so drawing it on the
// hand that is anchored to the right controller hangs a
// disembodied left sleeve in the air beside it.
//
// What is left is triggerhand, which is a whole hand and
// not a stump: it is weighted to Bip01 R Hand, every R
// finger bone, and helper Rwrist. The wrist is included,
// the forearm is not, and there is no way to get part of
// one without re-authoring the mesh - the forearm exists
// only inside the two-armed sleeve.
//
bVRHandTracked = qtrue;
CG_VRShowOnlySurface(&model, "triggerhand");
} else {
if (cg.snap->ps.stats[STAT_HEALTH] > 0 && !cg_animationviewmodel->integer) {
CG_OffsetFirstPersonView(&model, qfalse);
}
}
AnglesToAxis(cg.refdefViewAngles, cg.refdef.viewaxis);
@@ -1520,6 +2083,26 @@ void CG_ModelAnim(centity_t *cent, qboolean bDoShaderTime)
// add to refresh list
cgi.R_AddRefEntityToScene(&model, s1->parent);
//
// Added in OPM
//
// The off hand, on its own controller. The same model again with
// everything but that one hand hidden, anchored so the hand's own bone
// lands on the controller rather than the model origin.
//
// It is a second entity rather than a second pose because the skeleton
// is shared: both copies are the same animation at the same frame, and
// only the transform differs.
//
if (bVRHandTracked && pszVROffHandSurf) {
refEntity_t offHand = model;
if (CG_VRPlaceOffHandModel(&offHand)) {
CG_VRShowOnlySurface(&offHand, pszVROffHandSurf);
cgi.R_AddRefEntityToScene(&offHand, s1->parent);
}
}
}
CG_UpdateEntityEmitters(s1->number, &model, cent);
+41
View File
@@ -331,6 +331,13 @@ functions exported to the main executable
int (*R_GetShaderWidth)(qhandle_t shader);
int (*R_GetShaderHeight)(qhandle_t shader);
void (*R_DrawBox)(float x, float y, float w, float h);
// Added in OPM
// A textured triangle at arbitrary screen coordinates, which is the
// only way to draw anything rotated - R_DrawStretchPic is axis
// aligned. The UI has had this all along as re.DrawTrianglePic and it
// is how the retail compass turns its face; cgame needed it once the
// compass moved here. See CG_VRHudRotatedPic.
void (*R_DrawTrianglePic)(const vec2_t vPoints[3], const vec2_t vTexCoords[3], qhandle_t hShader);
// =========== data shared with the client =============
void (*GetGameState)(gameState_t *gamestate);
@@ -440,6 +447,40 @@ functions exported to the main executable
size_t (*getConfigStringIdNormalized)(size_t num);
/**
* Where the weapon hand is, when a headset is driving the game.
*
* offset is the hand relative to the head, in engine units and the
* engine's frame - add it to the view origin. angles carry the
* controller's pitch and roll, and in YAW how far the hand leads the
* head, to be added to the view yaw. headHeight is the head above the
* floor in metres, so the weapon can sit at the player's real hand
* height rather than at eye level.
*
* False when there is no headset or the hand is untracked, in which
* case the caller keeps the placement it had.
*/
qboolean (*VR_GetWeaponPose)(vec3_t trackingOffset, vec3_t angles, float *baseYaw);
/**
* @brief Where one controller is, for a hand to be drawn on it.
*
* hand is 0 for the left and 1 for the right. Same frame, units and
* conventions as VR_GetWeaponPose, but the bare hand pose - none of
* what the weapon does to its own aim is folded in.
*
* False when there is no headset or that hand is untracked, in which
* case the caller should not draw that hand at all.
*/
qboolean (*VR_GetHandPose)(int hand, vec3_t trackingOffset, vec3_t angles, float *baseYaw);
/*
* The weapon wheel, for drawing it. True while it is held open, with
* the segment the controller is pointing at - or -1 for the dead zone
* in the middle, which chooses nothing.
*/
qboolean (*VR_GetWheel)(int *selection, const char **names, int maxNames, int *count);
cvar_t *fsDebug;
hdelement_t *HudDrawElements;
clientAnim_t *anim;
+75
View File
@@ -516,6 +516,41 @@ static int CG_CalcFov(void)
inwater = qfalse;
}
//
// Added in OPM
// In VR the eye frustum comes from the headset rather than from this fov,
// so a scope or a pair of binoculars would otherwise change nothing at all.
// What the renderer wants is the factor rather than the angle: it narrows
// the headset's own frustum by this much and leaves the composition layer
// claiming the full field of view, so the compositor magnifies the result
// back out to fill the display. That is a scope which costs no extra
// drawing and loses no resolution - the narrow view was rendered into the
// whole eye texture, so it comes out sharper than the unzoomed one.
//
// Published as a cvar because it is the ratio of two numbers that only
// exist here. The aspect correction above cancels between them, so this is
// simply the two camera fovs against each other.
//
{
static float lastZoom = -1.0f;
float zoom = 1.0f;
if (cg.camera_fov > 1.0f && cg_fov->value > 1.0f) {
zoom = tan(DEG2RAD(cg_fov->value) * 0.5f) / tan(DEG2RAD(cg.camera_fov) * 0.5f);
}
// Only ever narrower. Widening the view in a headset is a good way to
// make someone ill, and nothing asks for it deliberately.
if (zoom < 1.0f) {
zoom = 1.0f;
}
if (fabs(zoom - lastZoom) > 0.001f) {
lastZoom = zoom;
cgi.Cvar_Set("vr_fovZoom", va("%f", zoom));
}
}
// set it
cg.refdef.fov_x = fov_x;
cg.refdef.fov_y = fov_y;
@@ -726,6 +761,46 @@ void CG_EyeOffset(vec3_t *o_vOfs)
void CG_EyeAngles(vec3_t *o_vAngles)
{
//
// Added in OPM
//
// These angles are what the *game* thinks the player is looking along -
// they travel up in the usercmd as usereyes_t and end up as
// Player::m_vViewAng, which is what the use trace is aimed with.
//
// cg.refdefViewAngles is the wrong source for that in VR. It does not
// only hold the view: CG_OffsetFirstPersonView overwrites it with the
// player model's animated *head bone* orientation on the paths that place
// the view from the world position, and whatever is left there when this
// is sampled is what the game gets. Measured on device, that left a pitch
// pinned between 73 and 86 degrees - very nearly straight down - so every
// use press traced into the floor about 77 units below the player and
// caught whatever trigger happened to be underfoot. It worked often
// enough to look intermittent rather than broken.
//
// The player state's view angles are the head's, and only the head's:
// the client writes the headset's yaw and pitch into them every frame
// (CL_VRMove), and nothing animated gets a say. No recoil kick or lean in
// them either, which is right for reaching at a door handle.
//
// Flat play keeps the old source. There the two agree except for kick and
// lean, and changing where a desktop player's use is aimed is not what
// this is for.
//
{
vec3_t vTracking, vHandAngles;
float fBaseYaw = 0.0f;
if (cgi.VR_GetHandPose
&& (cgi.VR_GetHandPose(0, vTracking, vHandAngles, &fBaseYaw)
|| cgi.VR_GetHandPose(1, vTracking, vHandAngles, &fBaseYaw))) {
(*o_vAngles)[0] = cg.predicted_player_state.viewangles[0];
(*o_vAngles)[1] = cg.predicted_player_state.viewangles[1];
(*o_vAngles)[2] = cg.predicted_player_state.viewangles[2];
return;
}
}
(*o_vAngles)[0] = cg.refdefViewAngles[0];
(*o_vAngles)[1] = cg.refdefViewAngles[1];
(*o_vAngles)[2] = cg.refdefViewAngles[2];
+797
View File
@@ -0,0 +1,797 @@
/*
===========================================================================
Copyright (C) 2025 the OpenMoHAA team
This file is part of OpenMoHAA source code.
OpenMoHAA source code is free software; you can redistribute it
and/or modify it under the terms of the GNU General Public License as
published by the Free Software Foundation; either version 2 of the License,
or (at your option) any later version.
OpenMoHAA source code is distributed in the hope that it will be
useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with OpenMoHAA source code; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
===========================================================================
*/
// DESCRIPTION:
// The heads up display, gathered into one group for VR.
//
// Added in OPM.
//
// The retail HUD is five separate menus loaded from ui/hud_*.urc - health,
// ammo, compass, weapons, items - each aligned to a different corner of a 4:3
// screen. On a monitor those corners are a glance away. In a headset they are
// the far edge of the lens: technically visible, practically not, and no amount
// of moving them solves it, because they are five widgets that each carry their
// own frame, their own alignment, and their own 2D window.
//
// So this does not move them. It draws the same information again, in one
// place, from the same source they read from.
//
// That source turns out to be almost nothing: every one of those widgets is a
// formatter over cl.snap.ps.stats[], a flat array of thirty-two integers, and
// the .urc files are pure layout. hud_health.urc is "playerstat 0, maxplayerstat
// 1, draw it as a vertical bar with this shader". cgame has the same array as
// cg.snap->ps.stats[], so nothing has to be read back out of the UI at all.
//
// Drawn from cgame rather than as another widget for one reason beyond taste:
// cgame's 2D goes through the single full screen window that UIView3D::set2D
// sets up, and R_VRAdjust2DRect converges that window as a whole. A widget
// would arrive with its own window and land back in the bug that made the HUD
// impossible to fuse in the first place.
//
// The compass was an upright ring with letters orbiting it to begin with,
// because turning the retail card needs rotated geometry and cgame had no
// import that could draw any. It read as a blank plate, which is what a compass
// is without its dial, so R_DrawTrianglePic was added to the cgame imports - the
// UI has always had it, as re.DrawTrianglePic, and it is what the retail compass
// turns its own face with - and the three real layers are drawn here now.
//
// The damage ring and objective arrow share the card's origin and scale.
#include "cg_local.h"
//
// The layout, in design units. The cluster is authored in a box this size and
// then scaled to the display, so the numbers below can be read as a picture
// rather than as a resolution.
//
// +------------------------------------------+
// | (compass) Mission Objectives: |
// | ring - take the bunker |
// | - find the radio |
// | HEALTH [====----] AMMO [======--] 8|64|
// | M1 GARAND |
// | [pistol][rifle][smg][mg] |
// +------------------------------------------+
//
#define VRHUD_DESIGN_W 420.0f
#define VRHUD_DESIGN_H 190.0f
// The design box is authored against a 1080 line display and scaled from there,
// so it keeps the same angular size on any eye resolution.
#define VRHUD_DESIGN_REF 1080.0f
#define VRHUD_RING_R 50.0f // compass ring radius
#define VRHUD_RING_CX -150.0f
#define VRHUD_RING_CY -50.0f
#define VRHUD_OBJ_X -92.0f // objectives column, left edge
#define VRHUD_OBJ_TOP -92.0f
#define VRHUD_BAR_H 14.0f
#define VRHUD_BAR_W 104.0f
#define VRHUD_BAR_Y 26.0f
#define VRHUD_HEALTH_X -196.0f
#define VRHUD_AMMO_X 36.0f
#define VRHUD_NAME_Y 54.0f
#define VRHUD_WEAP_Y 72.0f
#define VRHUD_WEAP_W 42.0f
#define VRHUD_WEAP_H 21.0f
// Text is drawn by the font at its own size, so it is scaled separately from
// the boxes and bars - a 20 pixel font shrunk to fit a design box is not
// legible through a lens.
#define VRHUD_TEXT_SCALE 1.15f
// How long the group takes to go once its time is up. Long enough to read as a
// fade rather than a cut, short enough not to sit in the way.
#define VRHUD_FADE_MS 600.0f
typedef struct {
qboolean registered;
qhandle_t compassBack;
qhandle_t compassFace;
qhandle_t compassNeedle;
qhandle_t compassObj;
qhandle_t compassPain;
qhandle_t weapPistol;
qhandle_t weapRifle;
qhandle_t weapSmg;
qhandle_t weapMg;
} vrHudMedia_t;
static vrHudMedia_t vrHud;
static qboolean vrHudPainInitialized;
static int vrHudPainStat;
static int vrHudPainTime = -1;
static int vrHudLastTime;
void CG_InitVRHud(void)
{
memset(&vrHud, 0, sizeof(vrHud));
vrHudPainInitialized = qfalse;
vrHudPainTime = -1;
}
void CG_UpdateVRHud(void)
{
if (!cg.snap || (cg.snap->ps.pm_flags & PMF_INTERMISSION)) {
return;
}
const int stat = cg.snap->ps.stats[STAT_DAMAGEDIR];
// Observe hits even while the requested HUD is hidden. Otherwise opening
// it replays the last hit, however long ago it happened.
if (!vrHudPainInitialized || cg.time < vrHudLastTime) {
vrHudPainInitialized = qtrue;
vrHudPainTime = -1;
} else if (stat != vrHudPainStat) {
vrHudPainTime = cg.time;
}
vrHudPainStat = stat;
vrHudLastTime = cg.time;
}
/*
================
CG_VRHudRegister
Deliberately lazy rather than in CG_RegisterGraphics. This draws nothing until
the player is in a map with a snapshot, by which time the shaders are loadable;
registering from the media pass would put these in front of every level load
whether the HUD is switched on or not.
CG_InitVRHud drops the handles when a map or subsystem restarts. A map's start
time is not a renderer sequence: two loads can have the same start time.
================
*/
static void CG_VRHudRegister(void)
{
if (vrHud.registered) {
return;
}
vrHud.compassBack = cgi.R_RegisterShaderNoMip("textures/hud/compassback");
vrHud.compassFace = cgi.R_RegisterShaderNoMip("textures/hud/compassface");
vrHud.compassNeedle = cgi.R_RegisterShaderNoMip("textures/hud/compassneedle");
vrHud.compassObj = cgi.R_RegisterShaderNoMip("textures/hud/compassobjarrow");
vrHud.compassPain = cgi.R_RegisterShaderNoMip("textures/hud/compasspain");
vrHud.weapPistol = cgi.R_RegisterShaderNoMip("textures/hud/weap_pistol");
vrHud.weapRifle = cgi.R_RegisterShaderNoMip("textures/hud/weap_rifle");
vrHud.weapSmg = cgi.R_RegisterShaderNoMip("textures/hud/weap_smg");
vrHud.weapMg = cgi.R_RegisterShaderNoMip("textures/hud/weap_mg");
vrHud.registered = qtrue;
}
//
// The mapping from design units to pixels for this frame. Held in file statics
// because every helper below needs them and threading them through each call
// would be four more arguments saying the same thing.
//
static float vrHudScale; // pixels per design unit
static float vrHudOriginX; // pixels, centre of the cluster
static float vrHudOriginY;
static float CG_VRHudX(float designX)
{
return vrHudOriginX + designX * vrHudScale;
}
static float CG_VRHudY(float designY)
{
return vrHudOriginY + designY * vrHudScale;
}
/*
================
CG_VRHudFill
A rectangle in design units, in a colour. Everything here that is not a piece of
retail art is one of these - bars, pips, the marker on the compass, the backing
plate - because a flat quad needs no asset to go looking for and stays legible
at any size.
Drawn with R_DrawBox, which is untextured and takes the colour straight from
R_SetColor, rather than by stretching a white shader.
That was the first attempt and it was wrong twice over. gfx/2d/blank - the
obvious candidate, and what cgame already registers for the lagometer - is
declared in scripts/gfx.shader as
alphaGen oneMinusVertex
rgbGen vertex
and that alphaGen is not a detail. It inverts the alpha: a bar asked for at
alpha 1 comes out at 0 and a backing plate asked for at 0.35 comes out at 0.65,
so the things meant to be solid were the faintest and the thing meant to be
faint was the most solid. Anything else built to be tinted has its own opinion
of the same sort. R_DrawBox has none - it is qglColor4ubv and a quad.
================
*/
static void CG_VRHudFill(float x, float y, float w, float h, float r, float g, float b, float a)
{
vec4_t color;
color[0] = r;
color[1] = g;
color[2] = b;
color[3] = a;
cgi.R_SetColor(color);
cgi.R_DrawBox(CG_VRHudX(x), CG_VRHudY(y), w * vrHudScale, h * vrHudScale);
}
/*
================
CG_VRHudPic
A shader stretched over a rectangle in design units, at full brightness or
dimmed. Dimming rather than hiding is what lets the weapon strip show which
slots exist as well as which are filled.
================
*/
static void CG_VRHudPic(float x, float y, float w, float h, qhandle_t shader, float bright, float alpha)
{
vec4_t color;
if (!shader) {
return;
}
color[0] = bright;
color[1] = bright;
color[2] = bright;
color[3] = alpha;
cgi.R_SetColor(color);
cgi.R_DrawStretchPic(
CG_VRHudX(x), CG_VRHudY(y), w * vrHudScale, h * vrHudScale, 0.0, 0.0, 1.0, 1.0, shader
);
}
/*
================
CG_VRHudRotatedPic
A square of art centred on a point in design units, turned clockwise by an angle
in degrees.
R_DrawStretchPic cannot do this - it takes a rectangle and draws it square to
the screen - so the quad is built by hand as two triangles and handed to
R_DrawTrianglePic, which is the same call the retail compass uses to turn its
own face.
Screen space has y running down, so a clockwise turn is
x' = dx cos - dy sin
y' = dx sin + dy cos
which sends the top corner to the right, as a compass card should when the
player turns to the left.
================
*/
static void CG_VRHudRotatedPic(float cx, float cy, float radius, float degrees, qhandle_t shader, float alpha)
{
static const float corner[4][2] = {
{-1.0f, -1.0f},
{ 1.0f, -1.0f},
{-1.0f, 1.0f},
{ 1.0f, 1.0f}
};
static const float uv[4][2] = {
{0.0f, 0.0f},
{1.0f, 0.0f},
{0.0f, 1.0f},
{1.0f, 1.0f}
};
vec2_t pts[4];
vec2_t tri[3], triUV[3];
vec4_t color;
float s, c;
int i;
if (!shader) {
return;
}
s = sin(DEG2RAD(degrees));
c = cos(DEG2RAD(degrees));
for (i = 0; i < 4; i++) {
const float dx = corner[i][0] * radius;
const float dy = corner[i][1] * radius;
pts[i][0] = CG_VRHudX(cx + (dx * c - dy * s));
pts[i][1] = CG_VRHudY(cy + (dx * s + dy * c));
}
color[0] = color[1] = color[2] = 1.0f;
color[3] = alpha;
cgi.R_SetColor(color);
// Two triangles, wound the same way round: 0-1-2 and 1-3-2.
for (i = 0; i < 2; i++) {
static const int index[2][3] = {
{0, 1, 2},
{1, 3, 2}
};
int k;
for (k = 0; k < 3; k++) {
tri[k][0] = pts[index[i][k]][0];
tri[k][1] = pts[index[i][k]][1];
triUV[k][0] = uv[index[i][k]][0];
triUV[k][1] = uv[index[i][k]][1];
}
cgi.R_DrawTrianglePic(tri, triUV, shader);
}
}
/*
================
CG_VRHudText
A string with its top left at a point in design units.
R_DrawString multiplies both the glyph size and the position by the scale vector
it is handed, so the position has to be divided back out by it - which is why
the coordinates given here are not the coordinates it receives. The same idiom
is in CG_DrawObjectives.
================
*/
static void CG_VRHudText(float x, float y, const char *text, float r, float g, float b, float a)
{
vec4_t color;
vec2_t textScale;
float s;
if (!text || !text[0] || !cgs.media.objectiveFont) {
return;
}
s = vrHudScale * VRHUD_TEXT_SCALE;
textScale[0] = s;
textScale[1] = s;
color[0] = r;
color[1] = g;
color[2] = b;
color[3] = a;
cgi.R_SetColor(color);
cgi.R_DrawString(cgs.media.objectiveFont, text, CG_VRHudX(x) / s, CG_VRHudY(y) / s, -1, textScale);
}
/*
================
CG_VRHudBar
A meter: a dark trough with a coloured fill and a thin outline, in design units.
Drawn as flat quads rather than with textures/hud/healthmeter because the retail
meters are tall narrow art built to stand on end at the edge of a screen, and
this cluster wants them lying down next to each other.
================
*/
static void CG_VRHudBar(float x, float y, float frac, float r, float g, float b, float alpha)
{
if (frac < 0.0f) {
frac = 0.0f;
} else if (frac > 1.0f) {
frac = 1.0f;
}
CG_VRHudFill(x - 1.0f, y - 1.0f, VRHUD_BAR_W + 2.0f, VRHUD_BAR_H + 2.0f, 0.0f, 0.0f, 0.0f, alpha * 0.55f);
CG_VRHudFill(x, y, VRHUD_BAR_W, VRHUD_BAR_H, 0.12f, 0.12f, 0.10f, alpha * 0.85f);
if (frac > 0.0f) {
CG_VRHudFill(x, y, VRHUD_BAR_W * frac, VRHUD_BAR_H, r, g, b, alpha);
}
}
/*
================
CG_VRHudCompass
An upright ring with a turning compass card, damage ring and objective arrow.
Two angles do all of it, and both are decoded exactly the way the retail widgets
decode them, so they agree with the game rather than with a guess:
- Heading. UIFakkLabel::DrawStatCompass takes the eye angles and subtracts
stats[STAT_COMPASSNORTH] / 182, which is a sixteen bit angle - 65536 over
360. That difference is how far the player is turned from the map's north.
- The objective. hud_compass.urc draws it as "statbar spinner 0 3600" with
end angles -180 to 180, and DrawStatSpinner turns that into
stat / 3600 * 360 - 180 degrees. It is a plain spinner and not a
headingspinner, which is the variant that subtracts the view angles - so
the game has already made it relative to where the player is looking, and
it can be used as a screen angle directly.
Screen angles here are measured clockwise from the top of the ring. A direction
whose world yaw is a sits at yaw - a, because yaw increases anticlockwise in
this engine and clockwise is what a compass face reads.
================
*/
static void CG_VRHudCompass(float alpha)
{
vec3_t eyeAngles;
float heading;
//
// Three layers, the same three and in the same order as hud_compass.urc:
// the brass plate, the card turning on it, and the needle over the top.
//
CG_VRHudPic(
VRHUD_RING_CX - VRHUD_RING_R,
VRHUD_RING_CY - VRHUD_RING_R,
VRHUD_RING_R * 2.0f,
VRHUD_RING_R * 2.0f,
vrHud.compassBack,
1.0f,
alpha
);
CG_EyeAngles(&eyeAngles);
heading = eyeAngles[YAW] - cg.snap->ps.stats[STAT_COMPASSNORTH] / 182.0f;
//
// The card, turned clockwise by the heading.
//
// North is at the top of the texture, so turning the whole face by the
// heading puts north wherever north actually is - and because the engine's
// yaw increases anticlockwise while a compass reads clockwise, turning the
// player to the left walks the card to the right, which is what a compass
// held in the hand does.
//
CG_VRHudRotatedPic(
VRHUD_RING_CX, VRHUD_RING_CY, VRHUD_RING_R * 0.94f, heading, vrHud.compassFace, alpha
);
//
// Which way the last hit came from, fading out over a second and a half.
//
// The retail ring is "statbar headingspinner 0 3600" over stats
// [STAT_DAMAGEDIR], and headingspinner is the variant that does subtract
// the view angles - unlike the objective below - so the direction is in
// world terms and has to be brought round to the head. DrawStatSpinner
// does it as AngleSubtract(yaw, raw*360 + 180), and the +180 is because the
// stat points at the attacker while the ring marks where the damage
// arrived.
//
// The fade is its own timer rather than anything the stat carries: the
// widget starts it whenever the value changes and runs it down over 1500ms.
//
{
const int stat = cg.snap->ps.stats[STAT_DAMAGEDIR];
if (vrHudPainTime >= 0 && cg.time - vrHudPainTime < 1500) {
const float fade = 1.0f - (cg.time - vrHudPainTime) / 1500.0f;
float frac = (AngleSubtract(eyeAngles[YAW], (stat / 3600.0f) * 360.0f + 180.0f) + 180.0f) / 360.0f;
if (frac >= 0.0f && frac <= 1.0f) {
CG_VRHudRotatedPic(
VRHUD_RING_CX,
VRHUD_RING_CY,
VRHUD_RING_R * 0.94f,
frac * 360.0f - 180.0f,
vrHud.compassPain,
alpha * fade
);
}
}
}
//
// The objective, before the needle so the needle stays on top of it.
//
// stats[STAT_OBJECTIVECENTER] is already relative to where the player is
// looking - hud_compass.urc draws it as a plain spinner rather than the
// headingspinner variant, and it is the headingspinner that subtracts the
// view angles - so this is a screen angle as it stands. The decode is the
// one DrawStatSpinner does for "statbar spinner 0 3600" with end angles
// -180 to 180.
//
if (cg.snap->ps.stats[STAT_OBJECTIVECENTER]) {
CG_VRHudRotatedPic(
VRHUD_RING_CX,
VRHUD_RING_CY,
VRHUD_RING_R * 0.94f,
cg.snap->ps.stats[STAT_OBJECTIVECENTER] / 10.0f - 180.0f,
vrHud.compassObj,
alpha
);
}
//
// The needle, which does not move. It marks the way the player is facing,
// and everything else turns underneath it.
//
CG_VRHudPic(
VRHUD_RING_CX - VRHUD_RING_R,
VRHUD_RING_CY - VRHUD_RING_R,
VRHUD_RING_R * 2.0f,
VRHUD_RING_R * 2.0f,
vrHud.compassNeedle,
1.0f,
alpha
);
}
/*
================
CG_VRHudWeapons
The four weapon classes, dim for a slot the player does not have and lit for one
they do, with the equipped class boxed.
stats[STAT_WEAPONS] is a bitmask of WEAPON_CLASS_*, and stats[STAT_EQUIPPED_WEAPON]
is the same bitmask with the class in hand set - which is how the retail bar in
cl_ui.cpp decides what to enable, only it does it by passing enable and disable
events to named widgets in hud_weapons.urc.
================
*/
static void CG_VRHudWeapons(float alpha)
{
const int owned = cg.snap->ps.stats[STAT_WEAPONS];
const int equipped = cg.snap->ps.stats[STAT_EQUIPPED_WEAPON];
int i;
struct {
int bit;
qhandle_t shader;
} slots[4] = {
{WEAPON_CLASS_PISTOL, vrHud.weapPistol},
{WEAPON_CLASS_RIFLE, vrHud.weapRifle },
{WEAPON_CLASS_SMG, vrHud.weapSmg },
{WEAPON_CLASS_MG, vrHud.weapMg },
};
for (i = 0; i < 4; i++) {
const float x = (i - 2) * (VRHUD_WEAP_W + 6.0f) + 3.0f;
const qboolean have = (owned & slots[i].bit) ? qtrue : qfalse;
if (equipped & slots[i].bit) {
CG_VRHudFill(
x - 2.0f, VRHUD_WEAP_Y - 2.0f, VRHUD_WEAP_W + 4.0f, VRHUD_WEAP_H + 4.0f,
1.0f, 0.85f, 0.2f, alpha * 0.35f
);
}
CG_VRHudPic(
x, VRHUD_WEAP_Y, VRHUD_WEAP_W, VRHUD_WEAP_H, slots[i].shader,
have ? 1.0f : 0.35f, have ? alpha : alpha * 0.4f
);
}
}
/*
================
CG_DrawVRHud
One cluster, in the middle of the view, carrying everything the corners used to.
Placed and sized entirely from cvars so it can be dialled in from autoexec.cfg
with a push and a force-stop: there is no console in a headset, and a HUD is
exactly the kind of thing that can only be judged with it on.
================
*/
void CG_DrawVRHud(void)
{
float alpha;
float fade;
float health, maxHealth;
float clip, maxClip;
int i;
float objY;
if (!vr_hudPass || !vr_hudPass->integer || !vr_hud || !vr_hud->integer
|| !cg_hud || !cg_hud->integer) {
return;
}
if (!cg.snap || (cg.snap->ps.pm_flags & (PMF_NO_HUD | PMF_INTERMISSION))) {
return;
}
//
// Added in OPM
// Up on request, not all the time.
//
// A HUD that is always there is always in the way, and almost all of what
// this one carries is only wanted at the moment it is asked for - how much
// ammo is left, which way the objective is. So it is hidden, and the off
// hand's X button brings it up for vr_hudTime seconds before it fades.
//
// vr_hudShow is a timestamp the VR layer writes when the button goes down,
// on the same clock cgi.Milliseconds reads, so nothing has to be latched
// or cleared here and a press during the fade simply starts it again.
//
// vr_hud 2 pins it up, which is what to use while arguing with the layout.
//
fade = 1.0f;
if (vr_hud->integer != 2) {
const float hold = (vr_hudTime ? vr_hudTime->value : 3.0f) * 1000.0f;
const int shown = vr_hudShow ? vr_hudShow->integer : 0;
const int since = cgi.Milliseconds() - shown;
if (!shown || since < 0 || since > hold + VRHUD_FADE_MS) {
return;
}
if (since > hold) {
fade = 1.0f - (since - hold) / VRHUD_FADE_MS;
}
}
// Nothing to report before there is a player to report on, and dead men
// read no compasses.
if (cg.snap->ps.stats[STAT_HEALTH] <= 0) {
return;
}
alpha = vr_hudAlpha ? vr_hudAlpha->value : 1.0f;
if (alpha > 1.0f) {
alpha = 1.0f;
}
alpha *= fade;
if (alpha <= 0.01f) {
return;
}
CG_VRHudRegister();
//
// Design units to pixels. The reference height keeps the cluster the same
// angular size whatever the eye buffer turns out to be; the anchor is a
// fraction of the display so it means the same thing there too.
//
vrHudScale = (cgs.glconfig.vidHeight / VRHUD_DESIGN_REF)
* (vr_hudGroupScale ? vr_hudGroupScale->value : 1.0f);
if (vrHudScale < 0.1f) {
vrHudScale = 0.1f;
}
vrHudOriginX = cgs.glconfig.vidWidth * (vr_hudGroupX ? vr_hudGroupX->value : 0.5f);
vrHudOriginY = cgs.glconfig.vidHeight * (vr_hudGroupY ? vr_hudGroupY->value : 0.92f);
//
// A backing plate, so the whole group reads as one object against whatever
// the world happens to be doing behind it. Without it the text disappears
// over snow and the bars disappear over mud.
//
CG_VRHudFill(
-VRHUD_DESIGN_W * 0.5f,
-VRHUD_DESIGN_H * 0.5f,
VRHUD_DESIGN_W,
VRHUD_DESIGN_H,
0.0f,
0.0f,
0.0f,
alpha * 0.35f
);
CG_VRHudCompass(alpha);
//
// Objectives. The text is already gathered by CG_DrawObjectives from
// cg.Objectives[], which the configstrings fill; this only lays it out
// narrower and next to everything else.
//
objY = VRHUD_OBJ_TOP;
CG_VRHudText(VRHUD_OBJ_X, objY, cgi.LV_ConvertString("Mission Objectives:"), 0.85f, 0.75f, 0.2f, alpha);
objY += 20.0f;
for (i = 0; i < MAX_OBJECTIVES; i++) {
if (cg.Objectives[i].flags == OBJ_FLAG_NONE || (cg.Objectives[i].flags & OBJ_FLAG_HIDDEN)) {
continue;
}
if (cg.Objectives[i].flags & OBJ_FLAG_COMPLETED) {
CG_VRHudText(VRHUD_OBJ_X, objY, "x", 0.6f, 0.6f, 0.6f, alpha);
CG_VRHudText(
VRHUD_OBJ_X + 14.0f, objY, cgi.LV_ConvertString(cg.Objectives[i].text), 0.6f, 0.6f, 0.6f, alpha
);
} else {
CG_VRHudText(VRHUD_OBJ_X, objY, "-", 1.0f, 1.0f, 1.0f, alpha);
CG_VRHudText(
VRHUD_OBJ_X + 14.0f, objY, cgi.LV_ConvertString(cg.Objectives[i].text), 1.0f, 1.0f, 1.0f, alpha
);
}
objY += 17.0f;
// The cluster is a fixed box and a level can set a great many
// objectives; the ones that do not fit are better dropped than drawn
// through the health bar.
if (objY > VRHUD_BAR_Y - 22.0f) {
break;
}
}
//
// Health. stats[0] over stats[1], which is what hud_health.urc reads.
//
health = (float)cg.snap->ps.stats[STAT_HEALTH];
maxHealth = (float)cg.snap->ps.stats[STAT_MAXHEALTH];
if (maxHealth < 1.0f) {
maxHealth = 100.0f;
}
{
const float frac = Com_Clamp(0.0f, 1.0f, health / maxHealth);
CG_VRHudText(VRHUD_HEALTH_X, VRHUD_BAR_Y - 17.0f, "HEALTH", 0.7f, 0.6f, 0.05f, alpha);
// Green through amber to red, so the colour says as much as the length.
CG_VRHudBar(
VRHUD_HEALTH_X, VRHUD_BAR_Y, frac, frac > 0.5f ? (2.0f - frac * 2.0f) : 1.0f,
Com_Clamp(0.0f, 1.0f, frac * 1.6f), 0.12f, alpha
);
CG_VRHudText(
VRHUD_HEALTH_X + VRHUD_BAR_W + 8.0f, VRHUD_BAR_Y - 2.0f, va("%d", (int)health), 1.0f, 1.0f, 1.0f, alpha
);
}
//
// Ammo. The clip bar is stats[6] over stats[7]; the numbers beside it are
// the clip and the reserve, which is how every hud_ammo_*.urc reads them.
//
clip = (float)cg.snap->ps.stats[STAT_CLIPAMMO];
maxClip = (float)cg.snap->ps.stats[STAT_MAXCLIPAMMO];
if (maxClip >= 1.0f) {
CG_VRHudText(VRHUD_AMMO_X, VRHUD_BAR_Y - 17.0f, "AMMO", 0.7f, 0.6f, 0.05f, alpha);
CG_VRHudBar(VRHUD_AMMO_X, VRHUD_BAR_Y, clip / maxClip, 0.95f, 0.8f, 0.25f, alpha);
CG_VRHudText(
VRHUD_AMMO_X + VRHUD_BAR_W + 8.0f,
VRHUD_BAR_Y - 2.0f,
va("%d|%d", (int)clip, cg.snap->ps.stats[STAT_AMMO]),
1.0f,
1.0f,
1.0f,
alpha
);
}
//
// What is in the player's hands. activeItems[ITEM_WEAPON] indexes the
// weapon configstrings, which is where cg_viewmodelanim.c gets the same
// name from.
//
if (cg.snap->ps.activeItems[ITEM_WEAPON] >= 0) {
const char *name = CG_ConfigString(CS_WEAPONS + cg.snap->ps.activeItems[ITEM_WEAPON]);
if (name && name[0]) {
CG_VRHudText(-40.0f, VRHUD_NAME_Y, name, 0.7f, 0.6f, 0.05f, alpha);
}
}
CG_VRHudWeapons(alpha);
cgi.R_SetColor(NULL);
}
+63
View File
@@ -22,6 +22,9 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
// cl_cgame.c -- client system interaction with client game
#include "client.h"
#ifdef USE_OPENXR
#include "../vr/vr_common.h"
#endif
#include "cl_ui.h"
#include "cl_uiradar.h"
#include "../corepp/tiki.h"
@@ -517,6 +520,60 @@ void CL_R_AddPolyToScene( qhandle_t hShader, int numVerts, const polyVert_t *ver
re.AddPolyToScene( hShader, numVerts, verts, 1 );
}
/*
====================
CL_VR_GetWeaponPose
Hands the weapon hand's pose to cgame, so the view model sits where the
controller is rather than bolted to the middle of the screen.
Answers false without a headset, and cgame keeps its own placement.
====================
*/
static qboolean CL_VR_GetWeaponPose( vec3_t trackingOffset, vec3_t angles, float *baseYaw ) {
#ifdef USE_OPENXR
return VR_GetWeaponPose( trackingOffset, angles, baseYaw );
#else
return qfalse;
#endif
}
/*
====================
CL_VR_GetHandPose
Hands one controller's pose to cgame, so a hand can be drawn on it. Unlike the
weapon pose this is the bare hand, with nothing the weapon does to its own aim
folded in.
Answers false without a headset or for an untracked hand, and cgame does not
draw that hand at all.
====================
*/
static qboolean CL_VR_GetHandPose( int hand, vec3_t trackingOffset, vec3_t angles, float *baseYaw ) {
#ifdef USE_OPENXR
return VR_GetHandPose( hand, trackingOffset, angles, baseYaw );
#else
return qfalse;
#endif
}
/*
====================
CL_VR_GetWheel
The weapon wheel's state, so cgame can draw it. Nothing but a pass through -
the wheel is tracked where the controller is read.
====================
*/
static qboolean CL_VR_GetWheel( int *selection, const char **names, int maxNames, int *count ) {
#if defined(USE_OPENXR)
return VR_GetWheel( selection, names, maxNames, count );
#else
return qfalse;
#endif
}
/*
====================
CL_GetShaderPointer
@@ -747,6 +804,8 @@ void CL_InitCGameDLL( clientGameImport_t *cgi, clientGameExport_t **cge ) {
cgi->R_GetShaderHeight = re.GetShaderHeight;
cgi->R_DrawBox = re.DrawBox;
// Added in OPM, so the gathered VR HUD can turn the compass face.
cgi->R_DrawTrianglePic = re.DrawTrianglePic;
cgi->R_SwipeBegin = re.SwipeBegin;
cgi->R_SwipePoint = re.SwipePoint;
cgi->R_SwipeEnd = re.SwipeEnd;
@@ -827,6 +886,9 @@ void CL_InitCGameDLL( clientGameImport_t *cgi, clientGameExport_t **cge ) {
cgi->CL_ClearSavedCgameState = CL_ClearSavedCgameState;
cgi->getConfigStringIdNormalized = CPT_NormalizeConfigstring;
cgi->VR_GetWeaponPose = CL_VR_GetWeaponPose;
cgi->VR_GetHandPose = CL_VR_GetHandPose;
cgi->VR_GetWheel = CL_VR_GetWheel;
cgi->fsDebug = fs_debug;
cgi->HudDrawElements = cls.HudDrawElements;
@@ -909,6 +971,7 @@ void CL_InitClientSavedData( void ) {
CL_InitializeStopwatch();
}
/*
====================
CL_InitCGame
+184
View File
@@ -23,6 +23,9 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#include "client.h"
#include "cl_ui.h"
#ifdef USE_OPENXR
#include "../vr/vr_common.h"
#endif
unsigned frame_msec;
int old_com_frameTime;
@@ -54,6 +57,15 @@ kbutton_t in_strafe, in_speed;
qboolean in_guimouse;
#ifdef USE_OPENXR
cvar_t *cl_vrSnapAngle;
cvar_t *cl_vrSmoothTurn;
cvar_t *cl_vrTurnSpeed;
cvar_t *cl_vrDeadzone;
cvar_t *cl_vrWalkWithView;
cvar_t *cl_vrStepScale;
#endif
kbutton_t in_up, in_down;
#ifdef USE_VOIP
@@ -723,6 +735,162 @@ void CL_FinishMove( usercmd_t *cmd ) {
CL_CreateCmd
=================
*/
#ifdef USE_OPENXR
/*
=================
CL_VRMove
Turns the headset and its sticks into a usercmd.
Until now head tracking only moved the camera the renderer drew from, which
left the game itself unaware of it - objectives that ask the player to look at
something, and anything else that consults the player's view angles, never saw
it. Setting the view angles here is what makes the game agree with what the
player can see.
Aim comes from the head for the moment. Section 5 of the porting plan is right
that a shooter wants it on the controller instead, but that belongs with weapon
handling; looking at things has to work first.
=================
*/
static void CL_VRMove( usercmd_t *cmd ) {
static float snapYaw;
static qboolean turnCentred = qtrue;
vrInput_t in;
float deadzone, turn;
if ( !VR_Enabled() || !VR_GetInput( &in ) ) {
return;
}
deadzone = cl_vrDeadzone ? cl_vrDeadzone->value : 0.2f;
// Turning. Snap by default: a stick that rotates the world smoothly while
// the inner ear insists otherwise is the classic way to make people ill,
// and it costs nothing to offer both.
turn = in.turn;
if ( fabs( turn ) < deadzone ) {
turnCentred = qtrue;
} else if ( cl_vrSmoothTurn && cl_vrSmoothTurn->integer ) {
snapYaw -= turn * ( cl_vrTurnSpeed ? cl_vrTurnSpeed->value : 90.0f ) * (float)cls.frametime * 0.001f;
} else if ( turnCentred ) {
// One step per flick, not per frame.
snapYaw -= ( turn > 0 ? 1.0f : -1.0f ) * ( cl_vrSnapAngle ? cl_vrSnapAngle->value : 45.0f );
turnCentred = qfalse;
}
cl.viewangles[YAW] = snapYaw + in.headYaw;
cl.viewangles[PITCH] = in.headPitch;
// Tell the renderer how much of the head's heading it is about to see come
// back the other way. These angles reach the camera through the player
// state, and the renderer composes the live headset pose on top of that
// camera - so without this the heading is applied twice.
//
// Nothing to take off during a cutscene: the camera then comes from the
// game's own camera angles rather than the player's view angles, so the
// head's heading never entered it.
if ( cl.snap.valid && ( cl.snap.ps.pm_flags & PMF_CAMERA_VIEW ) ) {
VR_SetBaseYaw( 0.0f );
} else {
VR_SetBaseYaw( in.headYaw );
}
// The grips hold rungs on a ladder rather than steadying the weapon and
// reloading, and the VR layer cannot see the player state to know that.
VR_SetClimbing( ( cl.snap.valid && cl.snap.ps.pm_type == PM_CLIMBWALL ) ? qtrue : qfalse );
// And whether there is anything in the player's hand, which decides
// whether a grip closing on a holster draws from it or puts back into it.
VR_SetArmed( ( cl.snap.valid && cl.snap.ps.activeItems[1] >= 0 ) ? qtrue : qfalse );
// Walking follows the off hand rather than the gaze. Tying travel to where
// the player is looking means they cannot glance sideways without veering,
// which is what makes room scale movement feel like being dragged around;
// steering with the free hand lets them look wherever they like and keep
// walking in a straight line.
{
float forward = in.moveForward;
float side = in.moveRight;
float heading = 0.0f;
// Radial, and taken before the rotation below mixes the two axes.
// Measuring the components separately afterwards cuts a dead cross out
// of the middle of the stick's travel: a gentle push at forty five
// degrees splits into two components that are each under the threshold,
// so the player leans on the stick and nothing happens.
if ( sqrt( forward * forward + side * side ) < deadzone ) {
forward = 0.0f;
side = 0.0f;
}
if ( in.offhandTracked && !( cl_vrWalkWithView && cl_vrWalkWithView->integer ) ) {
heading = in.offhandYaw - in.headYaw;
}
if ( heading != 0.0f ) {
// forwardmove and rightmove are resolved against the view angles, so
// what goes in the usercmd is the stick expressed in the head's
// frame - which means rotating it back by however far the hand leads
// the head, not forward by it. The wrong sign here mirrors the
// steering rather than offsetting it: the player points the hand
// left and walks right, and only dead ahead behaves.
float radians = DEG2RAD( heading );
float c = cos( radians );
float sn = sin( radians );
float f = forward;
forward = f * c + side * sn;
side = side * c - f * sn;
}
// Room scale, added on top of the stick. The headset moving is only a
// camera offset until it reaches the usercmd: the view goes where the
// player walked and the character stays behind, so they lean through
// walls and never actually travel. Handing the step to the same pmove
// the stick feeds means the body goes too, and is stopped by the same
// things that stop it when walking on the stick.
{
float stepForward = in.stepForward;
float stepSide = in.stepRight;
// Into the view's frame, exactly as the stick is, and for the same
// reason: the step is measured in the play space, but forwardmove
// and rightmove are resolved against where the player is looking.
if ( in.headYaw != 0.0f ) {
float radians = DEG2RAD( -in.headYaw );
float c = cos( radians );
float sn = sin( radians );
float f = stepForward;
stepForward = f * c + stepSide * sn;
stepSide = stepSide * c - f * sn;
}
if ( cls.frametime > 0 ) {
// Units this frame into units a second, then into the fraction
// of a run that represents, which is what the usercmd carries.
const float perSecond = 1000.0f / (float)cls.frametime;
const float runSpeed = 250.0f;
const float factor = cl_vrStepScale ? cl_vrStepScale->value : 1.0f;
forward += stepForward * perSecond / runSpeed * factor;
side += stepSide * perSecond / runSpeed * factor;
}
}
if ( forward != 0.0f ) {
cmd->forwardmove = ClampChar( cmd->forwardmove + (int)( forward * 127.0f ) );
}
if ( side != 0.0f ) {
cmd->rightmove = ClampChar( cmd->rightmove + (int)( side * 127.0f ) );
}
}
}
#endif
usercmd_t CL_CreateCmd( void ) {
usercmd_t cmd;
vec3_t oldAngles;
@@ -745,6 +913,10 @@ usercmd_t CL_CreateCmd( void ) {
// get basic movement from joystick
CL_JoystickMove( &cmd );
#ifdef USE_OPENXR
CL_VRMove( &cmd );
#endif
// check to make sure the angles haven't wrapped
if ( cl.viewangles[PITCH] - oldAngles[PITCH] > 90 ) {
cl.viewangles[PITCH] = oldAngles[PITCH] + 90;
@@ -1210,6 +1382,18 @@ void CL_InitInput( void ) {
cl_nodelta = Cvar_Get ("cl_nodelta", "0", 0);
cl_debugMove = Cvar_Get ("cl_debugMove", "0", 0);
#ifdef USE_OPENXR
// Comfort options, all tunable on the headset rather than at build time.
cl_vrSnapAngle = Cvar_Get( "cl_vrSnapAngle", "45", CVAR_ARCHIVE );
cl_vrSmoothTurn = Cvar_Get( "cl_vrSmoothTurn", "0", CVAR_ARCHIVE );
cl_vrTurnSpeed = Cvar_Get( "cl_vrTurnSpeed", "90", CVAR_ARCHIVE );
cl_vrDeadzone = Cvar_Get( "cl_vrDeadzone", "0.2", CVAR_ARCHIVE );
cl_vrWalkWithView = Cvar_Get( "cl_vrWalkWithView", "0", CVAR_ARCHIVE );
// How much of a real step becomes a step in the game. 1 is one for one at
// vr_worldscale; higher covers more ground than the room has.
cl_vrStepScale = Cvar_Get( "cl_vrStepScale", "1", CVAR_ARCHIVE );
#endif
}
/*
+148
View File
@@ -29,6 +29,9 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#include "../qcommon/localization.h"
#include "../qcommon/bg_compat.h"
#include "../sys/sys_local.h"
#ifdef USE_OPENXR
#include "../vr/vr_common.h"
#endif
#include "../sys/sys_update_checker.h"
#include "../uilib/uimessage.h"
@@ -2677,12 +2680,121 @@ CL_Frame
==================
*/
/*
==================
CL_RunStartupCommand
Runs vr_testStart once, a moment after the player is actually in the world.
Setting up a weapon test otherwise means playing through to wherever the game
hands one over, every single build. There is no console in a headset to type
"give" into either, so the command has to come from the config - and it cannot
simply go in autoexec.cfg, which runs before there is a map, let alone a player
to give anything to.
The delay is because CA_ACTIVE arrives before the player entity has finished
spawning, and a give aimed at nothing is silently dropped.
==================
*/
static void CL_RunStartupCommand( void ) {
static qboolean fired;
static qboolean mapFired;
static int activeSince;
static int idleSince;
cvar_t *cmd;
// Loading the map from autoexec.cfg does not survive: that runs during
// Com_Init, and the game's own startup - the intro stages and then the
// menu - comes afterwards and tears it down again. So it waits here until
// the client is actually sitting at the menu doing nothing.
//
// CA_CINEMATIC counts as sitting at the menu. This is not obvious and it
// cost a launch: the enum reads "playing a cinematic or a static pic, not
// connected to a server", and MOHAA's main menu is drawn over a static pic -
// so the client is CA_CINEMATIC there, never CA_DISCONNECTED. Waiting only
// on the latter meant idleSince was cleared every frame the menu was up and
// the wait below could never accumulate, so the map simply never loaded.
if ( clc.state == CA_DISCONNECTED || clc.state == CA_CINEMATIC ) {
cvar_t *startMap;
activeSince = 0;
fired = qfalse;
if ( mapFired ) {
return;
}
if ( !idleSince ) {
idleSince = cls.realtime;
return;
}
// Long enough for the intro stages to have finished handing the client
// around; starting a map underneath them leaves the menu on top of it.
if ( cls.realtime - idleSince < 4000 ) {
return;
}
startMap = Cvar_Get( "vr_startMap", "", CVAR_TEMP );
mapFired = qtrue;
// Printed either way, and with the state that let it through, for the
// same reason vr_testStart below prints either way: a hook that silently
// does nothing looks exactly like one that never ran, and telling those
// two apart is the whole cost of a device round trip.
Com_Printf( "vr_startMap: idle in state %d, map is \"%s\"\n",
(int)clc.state, ( startMap && startMap->string[0] ) ? startMap->string : "" );
if ( startMap && startMap->string[0] ) {
Cbuf_AddText( va( "map %s\n", startMap->string ) );
}
return;
}
idleSince = 0;
if ( clc.state != CA_ACTIVE ) {
// Armed again for the next map, so this works on every load rather
// than only the first.
activeSince = 0;
fired = qfalse;
return;
}
if ( fired ) {
return;
}
if ( !activeSince ) {
activeSince = cls.realtime;
return;
}
if ( cls.realtime - activeSince < 1500 ) {
return;
}
cmd = Cvar_Get( "vr_testStart", "", CVAR_TEMP );
fired = qtrue;
// Printed either way. A hook that silently does nothing is indistinguishable
// from one that never ran, and telling those apart has cost enough already.
Com_Printf( "vr_testStart: in world, command is \"%s\"\n",
( cmd && cmd->string[0] ) ? cmd->string : "" );
if ( cmd && cmd->string[0] ) {
Cbuf_AddText( va( "%s\n", cmd->string ) );
}
}
void CL_Frame ( int msec ) {
if ( !com_cl_running->integer ) {
return;
}
CL_RunStartupCommand();
#ifdef USE_CURL
if(clc.downloadCURLM) {
CL_cURL_PerformDownload();
@@ -2898,6 +3010,13 @@ CL_ShutdownRef
============
*/
void CL_ShutdownRef( void ) {
#ifdef USE_OPENXR
// The session and its swapchains belong to the GL context that is about to
// be destroyed; left alone they would outlive it and every swapchain
// acquire afterwards would fail.
VR_DestroySession();
#endif
if ( re.Shutdown ) {
re.Shutdown( qtrue );
}
@@ -2932,6 +3051,12 @@ void CL_StartHunkUsers( qboolean rendererOnly ) {
if ( !cls.rendererRegistered ) {
cls.rendererRegistered = qtrue;
CL_BeginRegistration();
#ifdef USE_OPENXR
// The GL context exists from here, which is what the session binds to.
VR_CreateSession();
#endif
UI_ResolutionChange();
}
@@ -3187,6 +3312,22 @@ qboolean CL_IsRendererLoaded(void) {
CL_InitRef
============
*/
/*
============
CL_GetVRRenderResolution
============
*/
static qboolean CL_GetVRRenderResolution( int *width, int *height ) {
#ifdef USE_OPENXR
if ( VR_Enabled() ) {
VR_GetRenderResolution( width, height );
return ( *width > 0 && *height > 0 ) ? qtrue : qfalse;
}
#endif
return qfalse;
}
void CL_InitRef( void ) {
refimport_t ri;
refexport_t *ret;
@@ -3335,6 +3476,7 @@ void CL_InitRef( void ) {
ri.TIKI_GetLocalFromGlobal = CL_RefTIKI_GetLocalFromGlobal;
ri.SKEL_GetBoneParent = CL_RefSKEL_GetBoneParent;
ri.GetVRRenderResolution = CL_GetVRRenderResolution;
ri.SKEL_GetMorphWeightFrame = CL_RefSKEL_GetMorphWeightFrame;
ret = GetRefAPI( REF_API_VERSION, &ri );
@@ -3554,6 +3696,12 @@ void CL_Init( void ) {
Com_Printf( "----- Client Initialization -----\n" );
#ifdef USE_OPENXR
// Before the renderer, because the eye resolution the runtime asks for is
// what the window and the render targets are sized to.
VR_Init();
#endif
start = Sys_Milliseconds();
CL_ClearState ();
+102
View File
@@ -23,10 +23,15 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#include "client.h"
#include "cl_ui.h"
#ifdef USE_OPENXR
#include "../vr/vr_common.h"
#endif
qboolean scr_initialized; // ready to draw
stereoFrame_t s_scr_stereoFrame;
vrDrawMode_t cl_vrDrawMode = VRDRAW_ALL;
cvar_t *cl_timegraph;
cvar_t *cl_debuggraph;
cvar_t *cl_graphheight;
@@ -494,6 +499,103 @@ SCR_SimpleUpdateScreen
==================
*/
void SCR_SimpleUpdateScreen( void ) {
#ifdef USE_OPENXR
// In VR the frame is drawn once per eye, each into its own swapchain image,
// and handed to the compositor rather than to the window. Deliberately two
// passes and no more: a third, mono pass to produce a flat image would cost
// half the frame again.
if( VR_Enabled() ) {
if( VR_BeginFrame() ) {
if( VR_UseScreenLayer() ) {
// Flat content: draw it once onto a panel fixed in front of the
// viewer. Drawing it into both eyes instead would give it no
// stereo disparity while each eye still warps it through its own
// asymmetric frustum, and the two images would never fuse.
VR_PrepareScreenLayer();
UpdateStereoSide( STEREO_CENTER );
if( com_speeds->integer ) {
re.EndFrame( &time_frontend, &time_backend );
}
else {
re.EndFrame( NULL, NULL );
}
VR_FinishScreenLayer();
}
else {
int eye;
// Only worth splitting the frame when something else is going to
// draw the flat half. Otherwise the eye passes carry it, the way
// a flat frame does, and RB_SetGL2D converges it.
const qboolean split = VR_WristPanelEnabled();
cl_vrDrawMode = split ? VRDRAW_WORLD : VRDRAW_ALL;
Cvar_Set( "vr_hudPass", split ? "2" : "3" );
for( eye = 0; eye < VR_MAX_EYES; eye++ ) {
if( !VR_PrepareEye( eye ) ) {
continue;
}
// Deliberately STEREO_CENTER for both eyes.
// STEREO_LEFT/RIGHT select the GL_BACK_LEFT/RIGHT draw
// buffers of quad buffered stereo, a different mechanism
// entirely: here each eye already has its own framebuffer,
// and its viewpoint comes from the headset pose.
{
const int sceneStart = Sys_Milliseconds();
int issueStart;
UpdateStereoSide( STEREO_CENTER );
issueStart = Sys_Milliseconds();
// Always asked for here, not only under com_speeds: the
// frame timing report wants the split, and there is no
// console in a headset to turn com_speeds on with.
re.EndFrame( &time_frontend, &time_backend );
VR_TraceRenderTimes( time_frontend, time_backend );
VR_TraceSceneTimes( issueStart - sceneStart,
Sys_Milliseconds() - issueStart );
}
VR_FinishEye( eye );
}
// Then the HUD and any menus, once, onto the panel on the
// player's wrist. Once rather than per eye because it is flat:
// the compositor gives it the same disparity in both eyes by
// placing the quad in the room.
if( VR_WristPanelVisible() ) {
cl_vrDrawMode = VRDRAW_PANEL;
Cvar_Set( "vr_hudPass", "1" );
VR_PrepareWristPanel();
UpdateStereoSide( STEREO_CENTER );
if( com_speeds->integer ) {
re.EndFrame( &time_frontend, &time_backend );
}
else {
re.EndFrame( NULL, NULL );
}
VR_FinishWristPanel();
}
cl_vrDrawMode = VRDRAW_ALL;
Cvar_Set( "vr_hudPass", "0" );
}
}
VR_SubmitFrame();
return;
}
#endif
// if running in stereo, we need to draw the frame twice
if( cls.glconfig.stereoEnabled ) {
UpdateStereoSide( STEREO_LEFT );
+141 -2
View File
@@ -29,6 +29,9 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#include "../qcommon/q_version.h"
#include "cl_ui.h"
#ifdef USE_OPENXR
#include "../vr/vr_common.h"
#endif
#include "cl_uigamespy.h"
#include <chrono>
@@ -120,6 +123,13 @@ cvar_t *ui_gmboxspam;
cvar_t *ui_debugload;
cvar_t *sound_overlay;
cvar_t *ui_compass_scale;
// Added in OPM
// Where the compass sits, as fractions of the flat screen. hud_compass.urc
// aligns it left top, which in a headset is the far corner of the lens - it is
// there, and it is barely visible. See UI_PlaceVRCompass.
cvar_t *vr_compassX;
cvar_t *vr_compassY;
static cvar_t *ui_vrHud;
static intro_stage_t intro_stage;
static char server_mapname[64];
@@ -1818,10 +1828,78 @@ UI_Update
Updates the UI.
====================
*/
/*
====================
UI_PlaceVRCompass
Added in OPM.
hud_compass.urc aligns the compass left top, so it is drawn at 0,0. On a flat
screen that is a corner you can glance at; in a headset it is the far edge of
the lens, where it is technically visible and practically not.
Centred horizontally and set a little below the middle, which is where a thing
you check without breaking your aim wants to be. Fractions of the screen rather
than pixels so it means the same at any eye resolution, and the widget's own
size is taken off so the fraction places its centre rather than its corner.
Applied only when the numbers change. setFrame propagates the coordinate system
and fires signals, which is not something to do every frame for a widget that is
not moving - and the alignment flags are cleared at the same time, so nothing
puts it back.
====================
*/
static void UI_PlaceVRCompass(void)
{
UIWidget *widget;
UIRect2D frame;
#ifdef USE_OPENXR
if (!VR_Enabled()) {
return;
}
#else
return;
#endif
if (!hud_compass || !vr_compassX || !vr_compassY) {
return;
}
widget = hud_compass->GetContainerWidget();
//
// The alignment flags have to go first, or none of this survives.
// hud_compass.urc says "align left top", and UIWidget::AlignPosition
// turns that into a flat "pos.x = 0; pos.y = 0" every time it runs -
// which it does on Realign and ResetMotion, after this. Setting the frame
// without clearing the flags puts the compass back in the corner a moment
// later, which is exactly what it did.
//
widget->setAlign(0);
frame = widget->getFrame();
// Compare the actual frame so a resize or recreated menu is repositioned
// even when the cvars have not changed. Avoid relayout for an unchanged frame.
const float x = uid.vidWidth * vr_compassX->value - frame.size.width * 0.5f;
const float y = uid.vidHeight * vr_compassY->value - frame.size.height * 0.5f;
if (frame.pos.x != x || frame.pos.y != y) {
frame.pos.x = x;
frame.pos.y = y;
widget->setFrame(frame);
}
}
void UI_Update(void)
{
Menu *currentMenu;
UIRect2D frame;
qboolean vrHudActive = qfalse;
#ifdef USE_OPENXR
vrHudActive = VR_Enabled() && ui_vrHud && ui_vrHud->integer;
#endif
re.SetRenderTime(cls.realtime);
CL_FillUIDef();
@@ -1835,7 +1913,30 @@ void UI_Update(void)
frame = uWinMan.getFrame();
view3d->Display(frame, 1.0);
if (ui_hud && !view3d->LetterboxActive()) {
//
// Added in OPM
// The health, ammo and compass widgets are flat, so in VR they belong
// on the wrist panel rather than in the eye buffers.
//
if (cl_vrDrawMode == VRDRAW_WORLD) {
return;
}
//
// Added in OPM
// The retail corner HUD, off while the gathered one is up.
//
// These five menus are aligned to the four corners of a 4:3 screen -
// hud_health.urc says "align left bottom", hud_compass.urc "align left
// top". On a monitor that is a glance away; through a lens it is the
// far edge, where they were reported as visible and unreadable. They
// are drawn again, together and in the middle, by CG_DrawVRHud, and
// leaving these up as well would draw everything twice.
//
// Read the current cvar value so the retail HUD can be restored
// without a rebuild. Desktop frames always keep their own HUD.
//
if (ui_hud && !view3d->LetterboxActive() && !vrHudActive) {
// draw the health hud
if (hud_health) {
hud_health->ForceShow();
@@ -1969,8 +2070,23 @@ void UI_Update(void)
}
// Hide the HUD when necessary
//
// Added in OPM
// ...including whenever the gathered VR HUD is up, which is the condition
// that actually turns the retail corner HUD off.
//
// Gating the explicit Display calls in the cls.no_menus branch above was
// not enough and looked like it was: the widgets kept drawing, measured
// off an eye dump with hud_health's meter still at the bottom left corner
// and hud_ammo's clip still at the bottom right, both exactly where their
// .urc alignment puts them. That branch returns early and is not the path
// taken here - when it is not, UI_Update falls through to
// uWinMan.UpdateViews(), which walks the whole widget tree and draws every
// shown menu whether anything asked it to or not. Hiding them is the only
// thing that covers both routes.
if (!ui_hud || clc.state != CA_ACTIVE || view3d->LetterboxActive() || (currentMenu && currentMenu->isFullscreen())
|| server_loading || ((cl.snap.ps.pm_flags & PMF_NO_HUD) || (cl.snap.ps.pm_flags & PMF_INTERMISSION))) {
|| server_loading || ((cl.snap.ps.pm_flags & PMF_NO_HUD) || (cl.snap.ps.pm_flags & PMF_INTERMISSION))
|| vrHudActive) {
if (crosshairhud) {
crosshairhud->ForceHide();
}
@@ -2393,6 +2509,25 @@ void UI_Update(void)
}
}
//
// Added in OPM
// An eye pass wants the 3D view and none of the widgets around it, so it
// goes straight at the view rather than walking the whole tree. Everything
// else in the tree is flat and is drawn in the panel pass instead.
//
if (cl_vrDrawMode == VRDRAW_WORLD) {
if (view3d && view3d->getShow()) {
view3d->Display(uWinMan.getFrame(), 1.0);
}
return;
}
#ifdef USE_OPENXR
VR_TraceEvent(VRTRACE_UPDATEVIEWS);
VR_TraceState((int)cl_vrDrawMode, Cvar_Get("vr_hudPass", "0", 0)->integer, cls.no_menus);
#endif
UI_PlaceVRCompass();
uWinMan.UpdateViews();
}
@@ -3887,6 +4022,10 @@ void UI_ResolutionChange(void)
ui_compass_scale = Cvar_Get("ui_compass_scale", "0.55", CVAR_ARCHIVE | CVAR_LATCH);
}
vr_compassX = Cvar_Get("vr_compassX", "0.5", CVAR_ARCHIVE);
vr_compassY = Cvar_Get("vr_compassY", "0.62", CVAR_ARCHIVE);
ui_vrHud = Cvar_Get("vr_hud", "1", CVAR_ARCHIVE);
CL_FillUIImports();
CL_FillUIDef();
+133 -35
View File
@@ -21,6 +21,9 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*/
#include "cl_ui.h"
#ifdef USE_OPENXR
#include "../vr/vr_common.h"
#endif
#include "../qcommon/localization.h"
#include "../server/server.h"
@@ -47,9 +50,11 @@ View3D::View3D()
setBorderStyle(border_none);
AllowActivate(true);
m_printfadetime = 0.0;
m_print_mat = NULL;
m_locationprint = qfalse;
m_printfadetime = 0.0;
m_printUpdateTime = cls.realtime;
m_subtitleUpdateTime = cls.realtime;
m_print_mat = NULL;
m_locationprint = qfalse;
}
void View3D::UpdateCenterPrint(const char *s, float alpha)
@@ -62,19 +67,21 @@ void View3D::UpdateCenterPrint(const char *s, float alpha)
m_print_mat = NULL;
}
m_printalpha = alpha;
m_printfadetime = 4000.0;
m_locationprint = qfalse;
m_printalpha = alpha;
m_printfadetime = 4000.0;
m_printUpdateTime = cls.realtime;
m_locationprint = qfalse;
}
void View3D::UpdateLocationPrint(int x, int y, const char *s, float alpha)
{
m_printstring = s;
m_printalpha = alpha;
m_printfadetime = 4000.0;
m_x_coord = x;
m_y_coord = y;
m_locationprint = qtrue;
m_printstring = s;
m_printalpha = alpha;
m_printfadetime = 4000.0;
m_printUpdateTime = cls.realtime;
m_x_coord = x;
m_y_coord = y;
m_locationprint = qtrue;
}
void View3D::FrameInitialized(void)
@@ -571,36 +578,88 @@ void View3D::DrawNetProfile(void)
void View3D::Draw2D(void)
{
#ifdef USE_OPENXR
VR_TraceEvent(VRTRACE_DRAW2D);
#endif
#ifdef USE_OPENXR
const int fadeStart = Sys_Milliseconds();
int printStart = fadeStart;
int overlayStart = fadeStart;
int fadeEnd;
int overlayEnd = fadeStart;
#endif
if (!cls.no_menus) {
DrawFades();
}
DrawLetterbox();
#ifdef USE_OPENXR
fadeEnd = Sys_Milliseconds();
#endif
if ((cl_debuggraph->integer || cl_timegraph->integer) && !cls.no_menus) {
SCR_DrawDebugGraph();
} else if (!cls.no_menus) {
if (cge) {
#ifdef USE_OPENXR
const int cgameStart = Sys_Milliseconds();
VR_TraceEvent(VRTRACE_CGAME_HUD);
#endif
cge->CG_Draw2D();
#ifdef USE_OPENXR
VR_TraceHudTimes(Sys_Milliseconds() - cgameStart);
#endif
}
#ifdef USE_OPENXR
printStart = Sys_Milliseconds();
#endif
if (m_locationprint) {
LocationPrint();
} else {
CenterPrint();
}
#ifdef USE_OPENXR
overlayStart = Sys_Milliseconds();
#endif
if (!cls.no_menus) {
DrawSoundOverlay();
DrawNetProfile();
DrawSubtitleOverlay();
}
#ifdef USE_OPENXR
overlayEnd = Sys_Milliseconds();
#endif
}
if (fps->integer && !cls.no_menus) {
DrawFPS();
DrawProf();
}
#ifdef USE_OPENXR
// Everything from here accounted for: whatever the parts do not add up to
// is not in this function at all.
VR_TraceHudParts(0, fadeEnd - fadeStart, overlayStart - printStart,
overlayEnd - overlayStart, Sys_Milliseconds() - overlayEnd);
#endif
}
// Fixed in OPM
// Advance by elapsed client time, not by the number of rendered eyes.
void View3D::UpdatePrintTime(void)
{
const int elapsed = cls.realtime - m_printUpdateTime;
m_printUpdateTime = cls.realtime;
if (elapsed > 0) {
m_printfadetime = Q_max(0.0f, m_printfadetime - elapsed);
}
}
void View3D::CenterPrint(void)
@@ -611,6 +670,7 @@ void View3D::CenterPrint(void)
float x, y;
float w, h;
UpdatePrintTime();
if (!m_printfadetime) {
return;
}
@@ -659,12 +719,6 @@ void View3D::CenterPrint(void)
re.DrawStretchPic(x, y, w, h, 0, 0, 1, 1, mat);
}
m_printfadetime -= cls.frametime;
if (m_printfadetime < 0) {
m_printfadetime = 0;
}
}
void View3D::LocationPrint(void)
@@ -676,6 +730,7 @@ void View3D::LocationPrint(void)
float alpha;
UIRect2D frame;
UpdatePrintTime();
if (!m_printfadetime) {
m_locationprint = false;
return;
@@ -730,11 +785,6 @@ void View3D::LocationPrint(void)
);
m_font->setColor(UBlack);
m_printfadetime -= cls.frametime;
if (m_printfadetime < 0) {
m_printfadetime = 0;
}
}
void View3D::DrawLetterbox(void)
@@ -777,15 +827,61 @@ void View3D::DrawFades(void)
void View3D::Draw(void)
{
if (clc.state != CA_DISCONNECTED) {
#ifdef USE_OPENXR
const int drawStart = Sys_Milliseconds();
VR_TraceEvent(VRTRACE_VIEW3D);
#endif
//
// Added in OPM
// The one place the 3D scene and the flat content over it meet, and so the
// one place a VR frame has to be able to take them apart - see
// vrDrawMode_t. The eye passes want the scene and nothing else; the wrist
// panel wants everything else and not the scene.
//
if (clc.state != CA_DISCONNECTED && cl_vrDrawMode != VRDRAW_PANEL) {
SCR_DrawScreenField();
}
if (cl_vrDrawMode == VRDRAW_WORLD) {
//
// Added in OPM
// Everything flat has gone to the wrist panel except the reticle and
// the scope surround, which have to stay with the world - see
// CG_Draw2D, which draws only those while vr_hudPass says so.
//
#ifdef USE_OPENXR
int hudStart = Sys_Milliseconds();
#endif
if (cge && !cls.no_menus) {
set2D();
// Set2DWindow flushes the queued world commands. Charge that
// work to the world before starting the HUD timer.
#ifdef USE_OPENXR
hudStart = Sys_Milliseconds();
#endif
cge->CG_Draw2D();
}
#ifdef USE_OPENXR
VR_TraceViewTimes(hudStart - drawStart, Sys_Milliseconds() - hudStart);
#endif
return;
}
set2D();
#ifdef USE_OPENXR
// The transition issues the pending world draw calls; starting the timer
// before it incorrectly reports those calls as HUD setup time.
const int hudStart = Sys_Milliseconds();
#endif
re.SavePerformanceCounters();
Draw2D();
#ifdef USE_OPENXR
VR_TraceViewTimes(hudStart - drawStart, Sys_Milliseconds() - hudStart);
#endif
}
float avWidth = 0.0;
@@ -794,6 +890,7 @@ void View3D::InitSubtitle(void)
{
float totalWidth;
m_subtitleUpdateTime = cls.realtime;
for (int i = 0; i < 4; i++) {
subs[i] = Cvar_Get(va("subtitle%d", i), "", 0);
teams[i] = Cvar_Get(va("subteam%d", i), "0", 0);
@@ -816,6 +913,9 @@ void View3D::DrawSubtitleOverlay(void)
int i;
float minX, maxX;
int line;
const int elapsed = Q_max(0, cls.realtime - m_subtitleUpdateTime);
m_subtitleUpdateTime = cls.realtime;
subAlpha = Cvar_Get("subAlpha", "0.5", 0);
@@ -827,6 +927,15 @@ void View3D::DrawSubtitleOverlay(void)
fadeTime[i] = 2500 * ((strlen(subs[i]->string) / 68) + 1.f) + 1500;
subLife[i] = fadeTime[i];
Q_strncpyz(oldStrings[i], subs[i]->string, sizeof(oldStrings[i]));
} else {
fadeTime[i] = Q_max(0.0f, fadeTime[i] - elapsed);
}
if (fadeTime[i] <= 0) {
oldStrings[i][0] = 0;
if (subs[i]->string && subs[i]->string[0]) {
Cvar_Set(va("subtitle%d", i), "");
}
}
if (fadeTime[i] > subLife[i] - 750.f) {
@@ -836,17 +945,6 @@ void View3D::DrawSubtitleOverlay(void)
} else {
alpha[i] = 1.f;
}
fadeTime[i] -= cls.frametime;
if (fadeTime[i] < 0) {
// Clear the subtitle
fadeTime[i] = 0;
oldStrings[i][0] = 0;
if (subs[i]->string && subs[i]->string[0]) {
Cvar_Set(va("subtitle%d", i), "");
}
}
}
minX = m_screenframe.size.height - m_font->getHeight(getHighResScale()) * 10;
+3
View File
@@ -25,6 +25,8 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
class View3D : public UIWidget
{
float m_printfadetime;
int m_printUpdateTime;
int m_subtitleUpdateTime;
float m_printalpha;
str m_printstring;
UIReggedMaterial *m_print_mat;
@@ -47,6 +49,7 @@ protected:
void DrawSoundOverlay(void);
void CenterPrint(void);
void LocationPrint(void);
void UpdatePrintTime(void);
void OnActivate(Event *ev);
void OnDeactivate(Event *ev);
void DrawSubtitleOverlay(void);
+28
View File
@@ -406,6 +406,29 @@ typedef struct {
extern clientStatic_t cls;
/*
Which half of the frame is being drawn.
In VR the world and the flat content that sits over it cannot share a pass. The
world has to be drawn once per eye, from that eye's viewpoint; the HUD and the
menus have to be drawn exactly once, flat, onto a panel the compositor places in
the room - drawing them into the eye buffers instead leaves them with no stereo
disparity while each eye still warps them through its own asymmetric frustum, so
the two images never fuse. The screen space HUD looking wrong in a headset is
the same problem the menus had before they moved to a quad.
So a VR frame is three passes rather than two: the world into each eye, then the
flat content into the panel. This says which one is running. VRDRAW_ALL is every
non VR frame, where the two are drawn together as they always were.
*/
typedef enum {
VRDRAW_ALL, // everything, in one pass - the flat path
VRDRAW_WORLD, // the 3D scene only, once per eye
VRDRAW_PANEL // the HUD and menus only, once, onto the wrist panel
} vrDrawMode_t;
extern vrDrawMode_t cl_vrDrawMode;
//=============================================================================
//extern vm_t *cgvm; // interface to cgame dll or vm
@@ -701,6 +724,11 @@ void SCR_DrawSmallChar( int x, int y, int ch );
void UpdateStereoSide( stereoFrame_t s );
// Whether a menu covering the whole screen is up. True during the mission
// briefing and anything else that takes over the view while the player is
// still in the world.
qboolean UI_FullscreenMenuUp( void );
//
// cl_cin.c
//
+16
View File
@@ -497,6 +497,22 @@ typedef struct gameImport_s {
int (*pvssoundindex)(const char* name, int streamed);
void (*KickClientForReason)(int clientNum, const char *reason);
cvar_t *(*Cvar_Find)(const char *varName);
/**
* Where the weapon hand is pointing, when a headset is driving the game.
*
* out[PITCH] is the controller's own pitch; out[YAW] is how far the hand
* leads the head, to be added to the view yaw the game already has;
* out[ROLL] is unused. False when there is no headset or the hand is not
* tracked, in which case the caller keeps the aim it had.
*
* The angles come from the client, so this is a single player facility:
* a dedicated server has no headset to ask and always answers false.
*/
qboolean (*VR_GetWeaponAim)(vec3_t out);
qboolean (*VR_GetHeadOffset)(vec3_t out, float *baseYaw);
qboolean (*VR_GetClimbPull)(vec3_t out);
qboolean (*VR_GetThrow)(vec3_t velocity, float *baseYaw);
unsigned int (*Client_NumPendingCommands)(int clientNum);
unsigned int (*Client_MaxPendingCommands)(int clientNum);
+166 -1
View File
@@ -4179,6 +4179,53 @@ void Player::ClientMove(usercmd_t *ucmd)
setOrigin(origin);
CheckGround();
}
//
// Added in OPM
//
// Climbing a ladder by hand in VR.
//
// A ladder here is not moved along by velocity - the pmove is
// skipped entirely on a wall, and the player is carried by the
// root motion of the climb animation above. That is the right
// behaviour for a stick, and it is the wrong one for a pair of
// hands: the animation decides how fast the player goes, and a
// drag has to be the player deciding.
//
// So this runs after the animation, not instead of it. The climb
// animation still plays, so the arms on the ladder still look
// like arms on a ladder, and the distance actually travelled is
// the distance the hand pulled.
//
// The hand that has hold of a rung is not moving through the
// world; the world is moving past it. Hence the negation - the
// hand goes down and the player goes up.
//
// It goes through AnimMove for the same reason the animation does:
// the ladder has a top and a wall behind it, and a drag that
// ignored them would pull the player through both.
//
if (movecontrol == MOVECONTROL_CLIMBWALL && gi.VR_GetClimbPull) {
vec3_t vPull;
if (gi.VR_GetClimbPull(vPull)) {
Vector vMove;
// The play space offset is already in the engine's frame
// and its units, and a ladder is climbed vertically. Only
// the height is taken: the horizontal components of a pull
// are the player's arm swinging around the rung rather
// than any intent to travel, and acting on them would peel
// them off the ladder sideways.
vMove = Vector(0.0f, 0.0f, -vPull[2]);
if (vMove != vec_zero) {
AnimMove(vMove, &origin);
setOrigin(origin);
CheckGround();
}
}
}
}
}
@@ -7132,8 +7179,32 @@ void Player::PlayerAngles(void)
angles[1] = portableTurret->GetStartYaw();
}
//
// Added in OPM
// PmoveAdjustAngleSettings clamps where the player may look while they are
// on a ladder - pitch to 73 degrees, yaw to 70 either side of the ladder's
// facing. That is fine on a monitor, where the game owns the camera and can
// simply refuse to turn it. In a headset it is not: the camera is the
// player's head, and no amount of clamping stops them turning their neck.
// The game would hold its idea of the view still while the headset kept
// reporting the truth, and the two disagreeing is what makes a world
// appear to swim.
//
// So the body still gets adjusted - it is what climbs, and what everyone
// else sees on the ladder - and the view is put back afterwards. Looking
// over your shoulder halfway up a ladder is worth more than the clamp was.
//
static cvar_t *pVRFreeLook = gi.Cvar_Get("vr_freeLook", "0", 0);
vec3_t vUnclamped;
VectorCopy(v_angle, vUnclamped);
PmoveAdjustAngleSettings(v_angle, angles, &client->ps, &edict->s);
if (pVRFreeLook->integer) {
VectorCopy(vUnclamped, v_angle);
}
SetViewAngles(v_angle);
setAngles(angles);
}
@@ -12385,6 +12456,13 @@ int Player::getUseableEntities(int *touch, int maxcount, bool requiresLookAt)
Vector offset;
Vector max;
Vector min;
vec3_t vBodyEye;
static cvar_t *g_vrTraceUse = NULL;
if (!g_vrTraceUse) {
g_vrTraceUse = gi.Cvar_Get("vr_traceUse", "0", 0);
}
if ((g_gametype->integer != GT_SINGLE_PLAYER && IsSpectator()) || IsDead()) {
return 0;
@@ -12403,6 +12481,52 @@ int Player::getUseableEntities(int *touch, int maxcount, bool requiresLookAt)
start = m_vViewPos;
//
// Added in OPM
//
// In VR, start the look-at trace from where the player is actually
// looking from.
//
// m_vViewPos is the body's eye - the entity origin plus a fixed view
// height - and the camera is not there. R_VRComposeView adds the head's
// offset within the play space on top of it, so the two agree only while
// the body is keeping up with the head. They come apart in exactly the
// case this function exists to serve: leaning through a doorway or over a
// crate to reach the thing on the other side, where the body is stopped
// by the geometry and the head is not.
//
// The direction needs no such correction. m_vViewAng is already the
// headset's - the client writes the head's yaw and pitch straight into
// the view angles - so this trace has always pointed where the player is
// looking, and only ever started from the wrong place.
//
VectorCopy(start, vBodyEye);
{
vec3_t vHeadOffset;
float fBaseYaw = 0.0f;
if (gi.VR_GetHeadOffset && gi.VR_GetHeadOffset(vHeadOffset, &fBaseYaw)) {
vec3_t vEye, vBodyAngles, vBodyAxis[3];
int i;
VectorCopy(start, vEye);
// The same composition the renderer uses for the camera. The
// offset is in the play space, so it turns with the body's
// heading - which is the view yaw less however much of it came
// from the head, or a snap turn would leave it behind.
VectorSet(vBodyAngles, 0.0f, m_vViewAng[YAW] - fBaseYaw, 0.0f);
AnglesToAxis(vBodyAngles, vBodyAxis);
for (i = 0; i < 3; i++) {
VectorMA(vEye, vHeadOffset[i], vBodyAxis[i], vEye);
}
start = Vector(vEye);
}
}
if (requiresLookAt) {
min = Vector(-4.f, -4.f, -4.f);
max = Vector(4.f, 4.f, 4.f);
@@ -12427,7 +12551,48 @@ int Player::getUseableEntities(int *touch, int maxcount, bool requiresLookAt)
max = start + Vector(31.f, 31.f, 31.f);
}
return gi.AreaEntities(min, max, touch, maxcount);
{
const int num = gi.AreaEntities(min, max, touch, maxcount);
//
// Added in OPM
//
// "Use works about half the time" is not something that can be
// reasoned to an answer - the button, the trace and the box each have
// a way of coming up empty, and they all look the same from inside a
// headset. So this says which one it was.
//
// Everything needed to tell them apart is here: the body's eye, the
// eye actually used after the play space offset, how far the trace
// got, and what the box caught. A press that prints nothing at all
// did not reach the game and the fault is in the binding.
//
if (g_vrTraceUse && g_vrTraceUse->integer) {
int i;
gi.Printf(
"VR use: body eye %.0f %.0f %.0f -> traced from %.0f %.0f %.0f | ang %.0f %.0f | "
"frac %.2f end %.0f %.0f %.0f | %d in box\n",
vBodyEye[0], vBodyEye[1], vBodyEye[2],
start[0], start[1], start[2],
m_vViewAng[0], m_vViewAng[1],
requiresLookAt ? trace.fraction : 1.0f,
offset[0], offset[1], offset[2],
num
);
for (i = 0; i < num && i < 8; i++) {
gentity_t *hit = &g_entities[touch[i]];
gi.Printf("VR use: [%d] %s%s\n",
touch[i],
(hit->entity && hit->entity->getClassname()) ? hit->entity->getClassname() : "?",
hit->inuse ? "" : " (not in use)");
}
}
return num;
}
}
bool Player::IsPrimaryWeaponValid() const
+298 -2
View File
@@ -1233,7 +1233,31 @@ void Weapon::GetMuzzlePosition(vec3_t position, vec3_t vBarrelPos, vec3_t forwar
player = static_cast<Player *>(owner.Pointer());
if (forward || right || up) {
AngleVectors(player->m_vViewAng, forward, right, up);
Vector aimAng = player->m_vViewAng;
vec3_t vrAim;
//
// Added in OPM
//
// In VR the shot follows the weapon hand, not the head. The
// camera stays where the player is looking - moving it to the
// controller would be unusable - so only the trace moves.
//
// Pitch comes straight from the controller; the head
// contributes nothing to it, and blending the two would fight
// the player's own wrist. Yaw is the view yaw plus how far the
// hand leads the head, because the controller's absolute yaw
// means nothing to the game: the player can turn on the spot
// with the stick, and the play space has an arbitrary forward.
//
// This is what RTCWQuest does, at rtcw/src/game/g_weapon.c:1904.
//
if (gi.VR_GetWeaponAim && gi.VR_GetWeaponAim(vrAim)) {
aimAng[PITCH] = vrAim[PITCH];
aimAng[YAW] = AngleNormalize360(aimAng[YAW] + vrAim[YAW]);
}
AngleVectors(aimAng, forward, right, up);
}
VectorCopy(player->m_vViewPos, position);
@@ -1416,7 +1440,279 @@ void Weapon::Shoot(Event *ev)
switch (firetype[mode]) {
case FT_PROJECTILE:
ProjectileAttack(pos, forward, owner, projectileModel[mode], charge_fraction, 0, this);
{
//
// Added in OPM
//
// A thrown weapon in VR goes where the arm sent it.
//
// The flat game throws along the view at a speed set by how
// long the button was held, because a mouse has no other way
// to say "over there, gently". A hand does: the swing carries
// both the direction and the strength, and using anything
// else means the player winds up, lets go, and watches the
// grenade leave along their nose instead.
//
// Only for grenades. Rockets and the like are projectiles
// too, and they are fired rather than thrown - those keep the
// weapon's own aim.
//
Vector vThrowDir = forward;
float fThrowFraction = 0.0f;
qboolean bThrown = qfalse;
if ((GetWeaponClass() & WEAPON_CLASS_GRENADE) && gi.VR_GetThrow) {
vec3_t vVel;
float fBaseYaw = 0.0f;
if (gi.VR_GetThrow(vVel, &fBaseYaw)) {
Sentient *pOwner = (Sentient *)(Entity *)owner;
vec3_t vBodyAngles, vBodyAxis[3], vWorld;
float fSpeed;
int i;
// Into the world the same way every other play space
// vector here is: rotated by the heading the body has
// that the head did not give it.
VectorSet(vBodyAngles, 0.0f,
(pOwner ? pOwner->GetViewAngles()[YAW] : 0.0f) - fBaseYaw, 0.0f);
AnglesToAxis(vBodyAngles, vBodyAxis);
VectorClear(vWorld);
for (i = 0; i < 3; i++) {
VectorMA(vWorld, vVel[i], vBodyAxis[i], vWorld);
}
fSpeed = VectorLength(vWorld);
if (fSpeed > 1.0f) {
static cvar_t *vr_throwFull = NULL;
float fReference;
VectorScale(vWorld, 1.0f / fSpeed, vWorld);
//
// Both the direction and the strength come from
// the swing. This is a physical throw: the grenade
// leaves along the line the hand was travelling,
// at a speed set by how fast it was going.
//
// Which instant of the swing to read is the whole
// problem, and it took three tries to get right.
// Averaging across the release blends the wind up
// with the follow through and aims between them.
// Taking the peak of the whole swing catches the
// backswing - measured on device, four throws all
// came out around 0.95 *backwards*. What works is
// the peak of a short window ending at release,
// which is what the hand was doing as it let go;
// the same four throws then measured 0.89 to 0.94
// straight ahead, and an underhand lob came out
// genuinely upward at 0.58.
//
// RTCWQuest does not do this at all - its grenade
// (VrInputDefault.c, the backpack mode) is only a
// selection gesture, thrown afterwards by the
// game's own fire along the controller. Aiming by
// the controller is steadier and it is not what a
// throw is. This port does the harder thing on
// purpose.
//
//
// Strength as a fraction of the game's own throw,
// not as an absolute speed.
//
// An absolute number would have to be invented
// here and would then disagree with whatever the
// grenade was authored to do - and every thrown
// weapon has its own. The fraction is the knob the
// engine already uses for a held throw, so feeding
// it from the arm keeps all of that tuning and
// only changes what decides it.
//
// The reference is the peak speed of a hard throw,
// measured on device: four throws came in between
// 298 and 338 units a second, so 340 is about what
// a full effort looks like. Below that the throw
// scales down; a gentle lob stays a gentle lob.
//
if (!vr_throwFull) {
vr_throwFull = gi.Cvar_Get("vr_throwFull", "340", CVAR_ARCHIVE);
}
fReference = (vr_throwFull && vr_throwFull->value > 1.0f)
? vr_throwFull->value : 340.0f;
//
// Linear, with a floor.
//
// A square root curve was tried here and made
// everything stronger, including the overhand
// throw that was already right: the same swing
// that measured 89% linear came out at 94%, and
// softer ones moved far more - 192 units a second
// went from 56% to 75%. It read as the throw
// having become too sharp, because it had.
//
// The strong end was never the problem. Linear is
// what the arm expects, and a floor lifts the
// gentle throws that would otherwise drop at the
// player's feet without touching anything above
// it.
//
//
// Raised to a power, and the power is above one on
// purpose.
//
// A lob leaves at around 45 degrees, which is the
// angle that carries furthest - so a gentle
// underhand at half strength outruns a hard flat
// throw, and lobs sailed past where they were
// aimed. Linear strength is not enough to stop
// that, because the angle is doing the work.
//
// An exponent above one bends the gentle end down
// hard while barely touching the top, which is the
// reverse of the square root tried earlier - that
// lifted the bottom and made everything too sharp.
//
{
static cvar_t *vr_throwCurve = NULL;
float fCurve;
if (!vr_throwCurve) {
vr_throwCurve = gi.Cvar_Get("vr_throwCurve", "1.5", CVAR_ARCHIVE);
}
fCurve = (vr_throwCurve && vr_throwCurve->value > 0.1f)
? vr_throwCurve->value : 1.5f;
fThrowFraction = pow(fSpeed / fReference, fCurve);
}
// A floor rather than zero: the player has decided
// to throw, and a grenade that drops at their feet
// because the swing was polite is not a mechanic.
// Tunable, because where it belongs is a question
// about how it feels and every guess so far has
// cost a rebuild.
{
static cvar_t *vr_throwMin = NULL;
float fMin;
if (!vr_throwMin) {
vr_throwMin = gi.Cvar_Get("vr_throwMin", "0.2", CVAR_ARCHIVE);
}
fMin = vr_throwMin ? vr_throwMin->value : 0.2f;
if (fThrowFraction < fMin) {
fThrowFraction = fMin;
}
}
if (fThrowFraction > 1.0f) {
fThrowFraction = 1.0f;
}
//
// Damp the sideways component of a gentle throw.
//
// This is the one place here that deliberately
// does not report what the arm did. An underhand
// swing comes across the body, so the hand really
// is moving sideways as it lets go - measured on
// device, lobs drifted left by 0.35 and once by
// 0.73, more sideways than forward. That is honest
// physics and it is not what the player meant.
//
// Scaled by how gentle the throw is, so a hard
// overhand keeps its true direction - those were
// already landing where they were aimed and must
// not be touched - while a soft lob is pulled back
// towards where the player is facing.
//
{
static cvar_t *vr_throwDamp = NULL;
float fDamp, fViewYaw, fRad, fC, fS;
float fAheadC, fLeftC;
if (!vr_throwDamp) {
vr_throwDamp = gi.Cvar_Get("vr_throwDamp", "0.8", CVAR_ARCHIVE);
}
fDamp = (vr_throwDamp ? vr_throwDamp->value : 0.8f)
* (1.0f - fThrowFraction);
if (fDamp < 0.0f) {
fDamp = 0.0f;
} else if (fDamp > 0.95f) {
fDamp = 0.95f;
}
fViewYaw = pOwner ? pOwner->GetViewAngles()[YAW] : 0.0f;
fRad = -DEG2RAD(fViewYaw);
fC = cos(fRad);
fS = sin(fRad);
// Into the player's frame, damp across, and
// back out again.
fAheadC = vWorld[0] * fC - vWorld[1] * fS;
fLeftC = vWorld[0] * fS + vWorld[1] * fC;
fLeftC *= (1.0f - fDamp);
vWorld[0] = fAheadC * fC + fLeftC * fS;
vWorld[1] = -fAheadC * fS + fLeftC * fC;
VectorNormalize(vWorld);
}
vThrowDir = Vector(vWorld);
{
// The direction as it reaches the world, which
// is the only one worth reading. The play
// space vector logged on the VR side is
// relative to whichever way the room was
// facing when tracking started, so forward and
// backward cannot be told apart in it.
static cvar_t *vr_traceThrow = NULL;
if (!vr_traceThrow) {
vr_traceThrow = gi.Cvar_Get("vr_traceThrow", "0", 0);
}
if (vr_traceThrow && vr_traceThrow->integer) {
// Relative to where the player is facing,
// which is the only frame that answers the
// question being asked. World axes belong
// to the map and play space axes to the
// room; in neither can "straight ahead" be
// told from "ninety degrees off". Here
// ahead is +1 0 0, the player's left is
// 0 +1 0, and up is up.
const float fViewYaw = pOwner ? pOwner->GetViewAngles()[YAW] : 0.0f;
const float fRad = -DEG2RAD(fViewYaw);
const float fC = cos(fRad);
const float fS = sin(fRad);
const float fAhead = vWorld[0] * fC - vWorld[1] * fS;
const float fLeft = vWorld[0] * fS + vWorld[1] * fC;
gi.Printf("VR throw: ahead %.2f left %.2f up %.2f | speed %.0f -> %.0f%% of full\n",
fAhead, fLeft, vWorld[2], fSpeed, fThrowFraction * 100.0f);
}
}
bThrown = qtrue;
}
}
}
ProjectileAttack(pos, vThrowDir, owner, projectileModel[mode],
bThrown ? fThrowFraction : charge_fraction, 0, this);
}
break;
case FT_LANDMINE:
PlaceLandmine(pos, owner, projectileModel[mode], this);
@@ -54,11 +54,20 @@ void gsiCancelThread(GSIThreadID id)
//should i destroy the attributes here?
pthread_attr_destroy(&id.attr);
#ifdef __ANDROID__
// bionic has no thread cancellation at all. Detach instead, so the thread
// releases its own resources once it returns on its own.
if (pthread_detach(id.thread) != PTHREAD_NO_ERROR) {
gsDebugFormat(GSIDebugCat_Common, GSIDebugType_Misc, GSIDebugLevel_WarmError,
"Failed to detach thread\r\n");
}
#else
if (pthread_cancel(id.thread) != PTHREAD_NO_ERROR) {
//there was an error - how should we handle these? or should we?
gsDebugFormat(GSIDebugCat_Common, GSIDebugType_Misc, GSIDebugLevel_WarmError,
"Failed to cancel thread\r\n");
}
#endif
//free up memory and set to NULL
gsifree(&id.thread);
+8
View File
@@ -84,6 +84,14 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#define DLL_SUFFIX
#endif
#if defined(__ANDROID__)
// Android only extracts lib*.so out of an APK, so the game modules carry the
// prefix and are then found on the loader's own search path.
#define DLL_PREFIX "lib"
#else
#define DLL_PREFIX
#endif
#if defined(TARGET_LOCAL_SYSTEM)
# define ARCH_SUFFIX
#elif defined(TARGET_MULTIPLE_ARCHITECTURES)
+12 -5
View File
@@ -49,7 +49,6 @@ extern void (APIENTRYP qglUnlockArraysEXT) (void);
#define QGL_1_1_PROCS \
GLE(void, BindTexture, GLenum target, GLuint texture) \
GLE(void, BlendFunc, GLenum sfactor, GLenum dfactor) \
GLE(void, CallList, GLuint list) \
GLE(void, ClearColor, GLclampf red, GLclampf green, GLclampf blue, GLclampf alpha) \
GLE(void, Clear, GLbitfield mask) \
GLE(void, ClearStencil, GLint s) \
@@ -62,21 +61,17 @@ extern void (APIENTRYP qglUnlockArraysEXT) (void);
GLE(void, Disable, GLenum cap) \
GLE(void, DrawArrays, GLenum mode, GLint first, GLsizei count) \
GLE(void, DrawElements, GLenum mode, GLsizei count, GLenum type, const GLvoid *indices) \
GLE(void, DrawPixels, GLsizei width, GLsizei height, GLenum format, GLenum type, const GLvoid *pixels) \
GLE(void, Enable, GLenum cap) \
GLE(void, Finish, void) \
GLE(void, Flush, void) \
GLE(void, Fogf, GLenum pname, GLfloat param) \
GLE(void, Fogfv, GLenum pname, const GLfloat *params) \
GLE(void, Fogi, GLenum pname, GLint param) \
GLE(void, GenTextures, GLsizei n, GLuint *textures ) \
GLE(void, GetBooleanv, GLenum pname, GLboolean *params) \
GLE(GLenum, GetError, void) \
GLE(void, GetIntegerv, GLenum pname, GLint *params) \
GLE(const GLubyte *, GetString, GLenum name) \
GLE(void, LineStipple, GLint factor, GLushort pattern) \
GLE(void, LineWidth, GLfloat width) \
GLE(void, PixelZoom, GLfloat xfactor, GLfloat yfactor) \
GLE(void, PolygonOffset, GLfloat factor, GLfloat units) \
GLE(void, ReadPixels, GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, GLvoid *pixels) \
GLE(void, Scissor, GLint x, GLint y, GLsizei width, GLsizei height) \
@@ -108,10 +103,22 @@ extern void (APIENTRYP qglUnlockArraysEXT) (void);
GLE(void, VertexPointer, GLint size, GLenum type, GLsizei stride, const GLvoid *ptr) \
// OpenGL 1.0/1.1 and 3.2 core profile but not OpenGL ES 1.x
//
// CallList, DrawPixels, Fogi, LineStipple and PixelZoom exist in no version of
// OpenGL ES. They belong here rather than in QGL_1_1_PROCS: that list is shared
// with the ES paths, and requiring them there makes GLimp_GetProcAddresses fail
// on any ES context, which is what kept the GL2 renderer from starting on
// Android. Only renderergl1 calls them - every use in renderergl2 is inside an
// "#if 0 // FIXME: unimplemented (GL2)".
#define QGL_DESKTOP_1_1_PROCS \
GLE(void, CallList, GLuint list) \
GLE(void, ClearDepth, GLclampd depth) \
GLE(void, DepthRange, GLclampd near_val, GLclampd far_val) \
GLE(void, DrawBuffer, GLenum mode) \
GLE(void, DrawPixels, GLsizei width, GLsizei height, GLenum format, GLenum type, const GLvoid *pixels) \
GLE(void, Fogi, GLenum pname, GLint param) \
GLE(void, LineStipple, GLint factor, GLushort pattern) \
GLE(void, PixelZoom, GLfloat xfactor, GLfloat yfactor) \
GLE(void, PolygonMode, GLenum face, GLenum mode) \
// OpenGL 1.0/1.1 but not OpenGL 3.2 core profile or OpenGL ES 1.x
+16
View File
@@ -176,6 +176,22 @@ void GLimp_Init( qboolean fixedFunction );
void GLimp_Shutdown( void );
void GLimp_EndFrame( void );
// When something other than the window presents the finished frame - a VR
// compositor reading the eye textures - swapping the window as well presents
// a surface nothing is looking at, and blocks on the display's own refresh
// while doing it. Two presentation paths then run at unrelated rates, which
// shows up as flicker.
void GLimp_SetPresentsToWindow( qboolean presents );
// The renderer's one way of asking for a GL entry point. Everything the
// renderer calls has to come from the same implementation - see
// GLimp_GL4ES_GetProcAddress for what goes wrong when it does not.
void *GLimp_GetProcAddress( const char *name );
// Bind the window's GL context to the calling thread again. The VR layer needs
// one current to give the OpenXR session; see GLimp_MakeCurrent.
qboolean GLimp_MakeCurrent( void );
void GLimp_LogComment( char *comment );
void GLimp_Minimize(void);
+24
View File
@@ -88,6 +88,23 @@ typedef struct {
// if the pointers are not NULL, timing info will be returned
void (*EndFrame)( int *frontEndMsec, int *backEndMsec );
// Redirects everything the renderer would draw to the window into another
// framebuffer instead. Used to render an eye into an OpenXR swapchain
// image; zero restores the window. May be NULL on renderers that do not
// support it.
void (*SetDefaultFramebuffer)( unsigned int framebuffer );
// The eye being rendered, for stereo from a headset. origin and angles are
// the head pose relative to the game's camera, in engine units and degrees;
// the tangents are the eye's asymmetric frustum edges at unit distance.
// baseYaw is how much of the head's heading the game's camera already
// carries, and is taken back off before the head is composed onto it - see
// R_ApplyVRView. Passing NULL for origin returns to the ordinary flat
// projection.
void (*SetVRView)( const float *origin, const vec3_t *axis,
float tanLeft, float tanRight, float tanUp, float tanDown,
float baseYaw, int eye );
int (*MarkFragments)( int numPoints, const vec3_t *points, const vec3_t projection,
int maxPoints, vec3_t pointBuffer, int maxFragments, markFragment_t *fragmentBuffer, float fRadiusSquared );
@@ -331,6 +348,13 @@ typedef struct {
//
int (*SKEL_GetMorphWeightFrame)(void *skeletor, int index, float time, int *data);
int (*SKEL_GetBoneParent)(void *skeletor, int boneIndex);
// The per-eye render size a headset requires, or false when not in VR.
// Asked for directly rather than passed through cvars: r_mode and friends
// are latched and are registered by the renderer itself, so a value set
// before the renderer starts is not in effect for its first mode change -
// which left the whole frame drawn into one corner of the eye texture.
qboolean (*GetVRRenderResolution)(int *width, int *height);
} refimport_t;
+33 -17
View File
@@ -147,30 +147,46 @@ void GL_Cull( int cullType ) {
}
else
{
GLenum face;
qglEnable( GL_CULL_FACE );
if ( cullType == CT_BACK_SIDED )
{
if ( backEnd.viewParms.isMirror )
{
qglCullFace( GL_FRONT );
}
else
{
qglCullFace( GL_BACK );
}
face = backEnd.viewParms.isMirror ? GL_FRONT : GL_BACK;
}
else
{
if ( backEnd.viewParms.isMirror )
{
qglCullFace( GL_BACK );
}
else
{
qglCullFace( GL_FRONT );
}
face = backEnd.viewParms.isMirror ? GL_BACK : GL_FRONT;
}
#ifdef USE_GL4ES
// Name a third face first, so the one we actually want cannot be
// mistaken for a no-op.
//
// gl4es keeps its own copy of the cull face and returns early from
// glCullFace whenever the requested mode matches it
// (src/gl/face.c:14). Measured on the device, past gl4es and straight
// out of libGLESv3: its copy said GL_FRONT while the driver was
// culling GL_BACK. So the engine asked for GL_FRONT every frame, gl4es
// judged that redundant against a copy that had drifted, and the
// hardware went on removing precisely the faces Quake wants kept -
// its world polygons are back facing in GL's terms, which is why
// CT_FRONT_SIDED culls GL_FRONT in the first place.
//
// The symptom was the whole world invisible while models stayed put,
// because model shaders are CT_TWO_SIDED and never enable culling at
// all. Inverting the culled face did not help either: asking for
// GL_BACK matched the driver's real state, so the picture never
// changed. Only disabling culling outright brought the world back.
//
// GL_FRONT_AND_BACK is safe here - nothing is drawn between these two
// calls - and it guarantees the second differs from whatever gl4es
// believes, so the driver is always told.
qglCullFace( GL_FRONT_AND_BACK );
#endif
qglCullFace( face );
}
}
@@ -1489,7 +1505,7 @@ void RB_ExecuteRenderCommands( const void *data ) {
default:
// stop rendering
t2 = ri.Milliseconds ();
backEnd.pc.msec = t2 - t1;
backEnd.pc.msec += t2 - t1;
return;
}
}
+4 -1
View File
@@ -108,7 +108,10 @@ void R_IssueRenderCommands( qboolean runPerformanceCounters ) {
cmdList->used = 0;
if ( runPerformanceCounters ) {
int backEndMsec = backEnd.pc.msec;
R_PerformanceCounters();
// EndFrame still needs the time spent in earlier command batches.
backEnd.pc.msec = backEndMsec;
}
// actually start the commands going
@@ -127,7 +130,7 @@ Issue any pending commands and wait for them to complete.
====================
*/
void R_IssuePendingRenderCommands( void ) {
if ( !tr.registered ) {
if ( !tr.registered || !backEndData->commands.used ) {
return;
}
R_IssueRenderCommands( qfalse );
+15
View File
@@ -436,10 +436,20 @@ Set2DWindow
*/
void Set2DWindow(int x, int y, int w, int h, float left, float right, float bottom, float top, float n, float f) {
R_IssuePendingRenderCommands();
// Everything 2D in this renderer comes through here, RB_SetGL2D included,
// so this is the one place the headset's correction has to be applied.
//
// Applied to the rectangle and not to the ortho window, because the window
// is usually a single widget's and the correction is the whole display's.
// See R_VRAdjust2DRect.
R_VRAdjust2DRect(&x, &y, &w, &h);
qglViewport(x, y, w, h);
qglScissor(x, y, w, h);
qglMatrixMode(GL_PROJECTION);
qglLoadIdentity();
qglOrtho(left, right, bottom, top, n, f);
qglMatrixMode(GL_MODELVIEW);
@@ -471,6 +481,11 @@ RE_Scissor
================
*/
void RE_Scissor(int x, int y, int width, int height) {
// The UI clips each widget to its own frame right after setting the window
// for it, so this has to move with the window or a widget the headset's
// correction has shifted is clipped away at where it used to be.
R_VRAdjust2DRect(&x, &y, &width, &height);
qglEnable(GL_SCISSOR_TEST);
qglScissor(x, y, width, height);
}
+195 -3
View File
@@ -29,6 +29,8 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
static fontheader_sgl_t s_loadedFonts_sgl[MAX_LOADED_FONTS];
static int s_numLoadedFonts_sgl = 0;
static fontheader_t s_loadedFonts[MAX_LOADED_FONTS];
cvar_t *vr_traceFont;
static int s_numLoadedFonts = 0;
static float s_fontHeightScale = 1.0;
static float s_fontGeneralScale = 1.0;
@@ -533,10 +535,10 @@ void R_LoadFontShader(fontheader_sgl_t* font)
{
for (i = 0; i < fontshader->numUnfoggedPasses; i++)
{
if (fontshader->unfoggedStages[0] != NULL && fontshader->unfoggedStages[0]->active)
if (fontshader->unfoggedStages[i] != NULL && fontshader->unfoggedStages[i]->active)
{
fontshader->unfoggedStages[0]->rgbGen = CGEN_GLOBAL_COLOR;
fontshader->unfoggedStages[0]->alphaGen = AGEN_GLOBAL_ALPHA;
fontshader->unfoggedStages[i]->rgbGen = CGEN_GLOBAL_COLOR;
fontshader->unfoggedStages[i]->alphaGen = AGEN_GLOBAL_ALPHA;
}
}
@@ -546,6 +548,139 @@ void R_LoadFontShader(fontheader_sgl_t* font)
{
font->trhandle = r_sequencenumber;
}
//
// Fixed in OPM
// Keep the atlas alive, and fetch it again if it has already gone.
//
// Every string in the game drew as a solid black rectangle - not a black
// box with faint glyphs, which is the missing-alpha symptom, but flat
// 0,0,0 with no glyph structure in it at all. Measured at the draw call,
// the shader's own stage reported texnum 0: no texture. Sampling texture
// zero returns opaque black, and opaque black is exactly what the screen
// had.
//
// The lifetime is the cause. R_FreeUnusedImages runs at the end of every
// registration - RE_EndRegistration, so once per map load - and frees
// every image whose r_sequence is neither the current one nor -1, where
// freeing means memset of the whole image_t. The font atlas is loaded
// once, early, by whoever draws text first; the shader that refers to it
// is then found in the shader hash on every later call to this function
// and handed straight back, images untouched. So the shader survives a map
// load and the image it points at does not, and the pointer is left aimed
// at a zeroed slot.
//
// The trick above of forcing r_sequencenumber to -1 across R_FindShader is
// meant to prevent exactly this, and it does - but only for the load that
// actually creates the image. It does nothing for an atlas that some other
// path brought in first at an ordinary sequence number, which is the usual
// case: the menus draw text long before any of this runs.
//
// So mark it here, every time, where the font is known to still want it.
// And if it has already been taken, ask for it again by name - the name is
// gone from the image_t along with everything else, but it is recoverable,
// because the font's own name is what built the shader's name to begin
// with.
//
for (i = 0; i < fontshader->numUnfoggedPasses; i++)
{
shaderStage_t *st = fontshader->unfoggedStages[i];
image_t *img;
if (!st || !st->active) {
continue;
}
img = st->bundle[0].image[0];
if (!img || !img->texnum) {
char lost[MAX_QPATH];
//
// Asking for it by name is not enough on its own, and that was the
// first attempt. R_FindImageFileOld looks in the hash before it
// looks on disk, and the entry in the hash is the very image that
// has no texture - so it comes straight back, still broken, and
// nothing has happened. Measured: name intact, r_sequence pinned,
// texnum still zero.
//
// So take it out of the hash first. R_FreeImage unhooks it and
// clears the slot, which is what makes the next lookup miss and go
// to disk, where a fresh image_t gets a texture of its own. The
// name has to be copied out before that, because clearing the slot
// takes the name with it.
//
if (img && img->imgName[0]) {
Q_strncpyz(lost, img->imgName, sizeof(lost));
} else {
Com_sprintf(lost, sizeof(lost), "gfx/fonts/%s.tga", font->name);
}
if (img) {
R_FreeImage(img);
}
img = R_FindImageFileOld(lost, qfalse, qfalse, qfalse, GL_REPEAT, GL_REPEAT);
if (img) {
st->bundle[0].image[0] = img;
ri.Printf(PRINT_ALL, "R_LoadFontShader: reloaded the atlas '%s' for '%s', texnum %d\n",
lost, font->name, (int)img->texnum);
} else {
ri.Printf(PRINT_WARNING, "R_LoadFontShader: lost the atlas '%s' for '%s' and could not fetch it back\n",
lost, font->name);
}
}
if (img) {
// Never a candidate for R_FreeUnusedImages again. A font outlives
// any one map, so its atlas has to as well.
img->r_sequence = -1;
}
}
//
// Added in OPM, for the port.
//
// Every string in the UI draws as an opaque black box with the glyphs
// faintly visible in it, while every image beside it is correct. Both go
// through RB_BeginSurface/RB_EndSurface on the same immediate path, so the
// difference has to be the shader, and there are exactly two ways it can
// come out opaque:
//
// - the lookup missed the script and fell back to a default shader,
// which is GLS_DEFAULT and therefore opaque. "default 1" says so;
// - the script was found but the atlas lost its alpha on upload, so
// GL_SRC_ALPHA blends against 1 everywhere and covers the background.
// "internal 0x1907" is GL_RGB and means exactly that; 0x1908 is
// GL_RGBA and means the alpha survived.
//
// gfx/fonts/facfont-20.tga is greyscale in RGB - 0 where alpha is 0, about
// 144 on a glyph - so drawn without a working alpha it is a black box with
// grey letters, which is what the device shows.
//
// The stage's blend bits are printed raw. 0x65 is
// GLS_SRCBLEND_SRC_ALPHA | GLS_DSTBLEND_ONE_MINUS_SRC_ALPHA, what the
// script asks for. 0 is GLS_DEFAULT, which is opaque.
//
if (vr_traceFont && vr_traceFont->integer) {
const shaderStage_t *st = fontshader->numUnfoggedPasses ? fontshader->unfoggedStages[0] : NULL;
ri.Printf(
PRINT_ALL,
"font: \"%s\" default %d passes %d | blend 0x%x rgbGen %d alphaGen %d | image \"%s\" internal 0x%x"
" texnum %d\n",
fontshader->name,
(int)fontshader->defaultShader,
fontshader->numUnfoggedPasses,
st ? (unsigned int)(st->stateBits & (GLS_SRCBLEND_BITS | GLS_DSTBLEND_BITS)) : 0u,
st ? (int)st->rgbGen : -1,
st ? (int)st->alphaGen : -1,
(st && st->bundle[0].image[0]) ? st->bundle[0].image[0]->imgName : "none",
(st && st->bundle[0].image[0]) ? (unsigned int)st->bundle[0].image[0]->internalFormat : 0u,
(st && st->bundle[0].image[0]) ? (int)st->bundle[0].image[0]->texnum : -1
);
}
}
void R_DrawString_sgl(fontheader_sgl_t* font, const char* text, float x, float y, int maxlen, const float *pvVirtualScreen) {
@@ -585,6 +720,63 @@ void R_DrawString_sgl(fontheader_sgl_t* font, const char* text, float x, float y
R_LoadFontShader(font);
}
//
// Added in OPM
// What the GL is actually set to at the moment a string is about to be
// drawn, once a second while vr_traceFont is on.
//
// Every string in the eye buffers comes out as a solid black rectangle -
// measured off a real eye dump, not a compositor screenshot, so it is not
// a compression artefact: the box is 0,0,0 with no glyph structure in it
// at all. Opaque and black together rules out most of the candidates. The
// atlas is a 32 bit TGA with a real alpha channel, min 0 max 255, and the
// loader supports that; the shader is rgbGen global_color, alphaGen
// global_alpha over SRC_ALPHA/ONE_MINUS_SRC_ALPHA, which is right; and
// color2D cannot be black, because the bars drawn either side of the text
// through the same field come out green and amber.
//
// What is left is the texture not reaching the fragments, and that is a
// GL state question that no amount of reading the engine will answer -
// the state the engine believes it set and the state gl4es actually has
// are exactly the two things that come apart here. So this asks the
// driver instead of the renderer: what is bound, what is enabled, and
// what the blend is really doing.
//
if (vr_traceFont && vr_traceFont->integer) {
static int lastReport;
int now = ri.Milliseconds();
if (now - lastReport > 1000) {
const shader_t *sh = (const shader_t *)font->shader;
const shaderStage_t *st = (sh && sh->numUnfoggedPasses) ? sh->unfoggedStages[0] : NULL;
GLint boundTex = 0, srcBlend = 0, dstBlend = 0;
GLboolean blendOn = 0, texOn = 0;
lastReport = now;
qglGetIntegerv(GL_TEXTURE_BINDING_2D, &boundTex);
qglGetIntegerv(GL_BLEND_SRC, &srcBlend);
qglGetIntegerv(GL_BLEND_DST, &dstBlend);
blendOn = glIsEnabled(GL_BLEND);
texOn = glIsEnabled(GL_TEXTURE_2D);
ri.Printf(
PRINT_ALL,
"font draw: \"%s\" color2D %d %d %d %d | blend %d src 0x%x dst 0x%x | tex2d %d bound %d"
" want %d | img \"%s\" seq %d of %d | scale %.2f\n",
sh ? sh->name : "none",
backEnd.color2D[0], backEnd.color2D[1], backEnd.color2D[2], backEnd.color2D[3],
(int)blendOn, (unsigned int)srcBlend, (unsigned int)dstBlend,
(int)texOn, (int)boundTex,
(st && st->bundle[0].image[0]) ? st->bundle[0].image[0]->texnum : -1,
(st && st->bundle[0].image[0]) ? st->bundle[0].image[0]->imgName : "none",
(st && st->bundle[0].image[0]) ? st->bundle[0].image[0]->r_sequence : -999,
r_sequencenumber,
s_fontGeneralScale
);
}
}
charHeight = s_fontHeightScale * font->height * s_fontGeneralScale;
RB_BeginSurface((shader_t*)font->shader);
+52 -2
View File
@@ -573,7 +573,23 @@ static void Upload32(
}
else
{
internalFormat = 3;
//
// Fixed in OPM
// These were the literals 3 and 4 - OpenGL 1.x shorthand
// for "three components" and "four components". Legal on
// the desktop and meaningless in OpenGL ES, where
// internalformat has to be a real enum, so gl4es had
// nothing to map and the alpha was dropped on upload.
//
// It cost every string in the UI. A font atlas is white
// glyphs in the alpha channel over a black RGB, so without
// alpha each one drew as an opaque black block - measured
// on the device as "internal 0x4" against a shader whose
// blend and rgbGen were both correct. Images were fine
// throughout, because an opaque texture loses nothing when
// its alpha goes.
//
internalFormat = GL_RGB; // was the literal 3
}
}
else
@@ -603,7 +619,8 @@ static void Upload32(
}
else
{
internalFormat = 4;
// See the note on GL_RGB above.
internalFormat = GL_RGBA; // was the literal 4
}
}
else
@@ -888,6 +905,39 @@ image_t* R_CreateImageOld(
// Here, by using glGenTextures, it will guarantee the uniqueness of the texture
qglGenTextures(1, &image->texnum);
//
// Added in OPM
// glGenTextures never legitimately hands back 0 - it is the reserved
// "no texture" name - so a zero here means the call did not write, and
// everything downstream then works on texture zero without complaining.
// Upload32 uploads into it, GL_Bind binds it, and the surface samples
// opaque black. That is what every string in the game looked like: a flat
// black rectangle with no glyph structure at all, because the font atlas
// had been uploaded into the null texture.
//
// Asking again costs one call on a path that has just read a file off
// disk, and it turns a silently broken image into either a working one or
// a line in the log.
//
if (!image->texnum) {
qglGenTextures(1, &image->texnum);
ri.Printf(
image->texnum ? PRINT_DEVELOPER : PRINT_WARNING,
"R_CreateImage: \"%s\" got no texture name; asked again and got %d\n",
name,
(int)image->texnum
);
}
// Every font atlas, named, as it is created. Which of the two things is
// happening to a broken font cannot be told apart from the outside: an
// image that was never created, and one that was created without a texture,
// look identical by the time anything draws with it.
if (vr_traceFont && vr_traceFont->integer && !Q_stricmpn(name, "gfx/fonts/", 10)) {
ri.Printf(PRINT_ALL, "image create: \"%s\" slot %d texnum %d\n", name, i, (int)image->texnum);
}
image->numMipmaps = numMipmaps;
image->allowPicmip = allowPicmip;
image->dynamicallyUpdated = dynamicallyUpdated;
+49
View File
@@ -50,6 +50,13 @@ cvar_t *r_skipBackEnd;
cvar_t *r_ignorehwgamma;
cvar_t *r_measureOverdraw;
cvar_t *r_vrTrace;
cvar_t *vr_fovZoom;
cvar_t *vr_hudScale;
cvar_t *vr_hudDepth;
cvar_t *vr_hudOffset;
cvar_t *vr_traceHud;
cvar_t *r_inGameVideo;
cvar_t *r_fastsky;
cvar_t *r_fastdlights;
@@ -405,6 +412,15 @@ static int s_numVidModes = ( sizeof( r_vidModes ) / sizeof( r_vidModes[0] ) );
qboolean R_GetModeInfo( int *width, int *height, float *windowAspect, int mode ) {
vidmode_t *vm;
// A headset dictates the render size; whatever r_mode says is irrelevant.
// Asked for directly rather than read from a cvar because r_mode is latched
// and is registered by this renderer, so a value the client sets beforehand
// is not in effect for the first mode change.
if ( ri.GetVRRenderResolution && ri.GetVRRenderResolution( width, height ) ) {
*windowAspect = (float)*width / (float)*height;
return qtrue;
}
if ( mode < -1 ) {
return qfalse;
}
@@ -1201,11 +1217,20 @@ void BuildGfxInfo(char* dest, size_t destsize) {
PrintAndAppendString( dest, destsize, "rendering primitives: " );
primitives = r_primitives->integer;
if ( primitives == 0 ) {
// Kept in step with R_DrawElements deliberately. This is a second
// copy of that decision, and while it disagreed it reported
// "multiple glArrayElement" for a renderer that was issuing
// glDrawElements - which is the only place anyone can read the
// answer on a device with no console.
#ifdef USE_GL4ES
primitives = 2;
#else
if ( qglLockArraysEXT ) {
primitives = 2;
} else {
primitives = 1;
}
#endif
}
if ( primitives == -1 ) {
PrintAndAppendString( dest, destsize, "none\n" );
@@ -1400,6 +1425,17 @@ void R_Register( void )
r_fastdlights = ri.Cvar_Get("r_fastdlights", "0", CVAR_ARCHIVE | CVAR_LATCH);
r_drawSun = ri.Cvar_Get( "r_drawSun", "0", CVAR_ARCHIVE );
r_dlightBacks = ri.Cvar_Get( "r_dlightBacks", "1", CVAR_ARCHIVE );
// Added in OPM
// Deliberately not CVAR_ARCHIVE: a diagnostic that survives into the next
// run is one that gets left on. See R_TraceDlight in tr_light.c.
vr_traceDlight = ri.Cvar_Get( "vr_traceDlight", "0", 0 );
// Added in OPM
// Defaults on, which is the engine's own behaviour. It is turned off in
// misc/android/autoexec.cfg, and only there, because the bug it works
// around has only ever been seen on the device. See
// docs/markdown/04-coding/05-dlights.md.
r_worldDlights = ri.Cvar_Get( "r_worldDlights", "1", CVAR_ARCHIVE );
vr_traceFont = ri.Cvar_Get( "vr_traceFont", "0", 0 );
r_finish = ri.Cvar_Get ("r_finish", "0", CVAR_ARCHIVE);
r_textureMode = ri.Cvar_Get( "r_textureMode", "gl_linear_mipmap_linear", CVAR_ARCHIVE );
r_swapInterval = ri.Cvar_Get( "r_swapInterval", "0", CVAR_ARCHIVE | CVAR_LATCH );
@@ -1560,6 +1596,17 @@ void R_Register( void )
r_ext_multisample = ri.Cvar_Get("r_ext_multisample", "0", CVAR_ARCHIVE | CVAR_LATCH);
r_noborder = ri.Cvar_Get("r_noborder", "0", CVAR_ARCHIVE | CVAR_LATCH);
r_vrTrace = ri.Cvar_Get( "r_vrTrace", "0", 0 );
// Set by cgame from the fov the game asked for; see CG_CalcFov.
vr_fovZoom = ri.Cvar_Get( "vr_fovZoom", "1", 0 );
// See R_VRAdjust2DRect. Scale is the share of the display the HUD is drawn
// across; depth is how far out it is made to converge. Offset is how much
// of the per-eye optical-axis centring to apply, and wants to be 1 - it is
// separable only so that the term can be taken out while looking at it.
vr_hudScale = ri.Cvar_Get( "vr_hudScale", "0.55", CVAR_ARCHIVE );
vr_hudDepth = ri.Cvar_Get( "vr_hudDepth", "2.0", CVAR_ARCHIVE );
vr_hudOffset = ri.Cvar_Get( "vr_hudOffset", "1", CVAR_ARCHIVE );
vr_traceHud = ri.Cvar_Get( "vr_traceHud", "0", 0 );
r_ext_texture_filter_anisotropic = ri.Cvar_Get("r_ext_texture_filter_anisotropic",
"0", CVAR_ARCHIVE | CVAR_LATCH);
r_stereoEnabled = ri.Cvar_Get("r_stereoEnabled", "0", CVAR_ARCHIVE | CVAR_LATCH);
@@ -1878,6 +1925,8 @@ refexport_t *GetRefAPI ( int apiVersion, refimport_t *rimp ) {
re.BeginFrame = RE_BeginFrame;
re.EndFrame = RE_EndFrame;
re.SetDefaultFramebuffer = RE_SetDefaultFramebuffer;
re.SetVRView = RE_SetVRView;
re.MarkFragments = R_MarkFragments;
re.LerpTag = R_LerpTag;
+219 -4
View File
@@ -138,6 +138,25 @@ int R_RealDlightPatch(srfGridMesh_t *srf, int dlightBits)
float *origin;
drawVert_t *dv;
//
// Added in OPM
// Off, the world stops responding to dynamic lights entirely: no atlas
// block for any surface, so tess.dlightMap stays 0 and the texcoords are
// never shifted. Lights still exist and still light models and sprites -
// only the world is left alone.
//
// This is a workaround, not a feature. A muzzle flash currently makes the
// ground four times *darker* rather than lighting it, and the cause is
// not found - see docs/markdown/04-coding/05-dlights.md for what was
// measured and what is ruled out. Losing the flash lighting the scenery
// is a smaller loss than the ground going black every time a shot is
// fired, so it is off on the device until this is understood.
//
if (!r_worldDlights->integer) {
srf->dlightMap[tr.smpFrame] = 0;
return 0;
}
if (!srf->lmHeight || !srf->lmWidth) {
srf->dlightMap[tr.smpFrame] = 0;
return 0;
@@ -304,6 +323,25 @@ int R_RealDlightFace(srfSurfaceFace_t *srf, int dlightBits)
qboolean added;
float *origin;
//
// Added in OPM
// Off, the world stops responding to dynamic lights entirely: no atlas
// block for any surface, so tess.dlightMap stays 0 and the texcoords are
// never shifted. Lights still exist and still light models and sprites -
// only the world is left alone.
//
// This is a workaround, not a feature. A muzzle flash currently makes the
// ground four times *darker* rather than lighting it, and the cause is
// not found - see docs/markdown/04-coding/05-dlights.md for what was
// measured and what is ruled out. Losing the flash lighting the scenery
// is a smaller loss than the ground going black every time a shot is
// fired, so it is off on the device until this is understood.
//
if (!r_worldDlights->integer) {
srf->dlightMap[tr.smpFrame] = 0;
return 0;
}
if (!srf->lmHeight || !srf->lmWidth) {
srf->dlightMap[tr.smpFrame] = 0;
return 0;
@@ -404,6 +442,25 @@ int R_RealDlightTerrain(cTerraPatchUnpacked_t *srf, int dlightBits)
float z00, z01;
float z10, z11;
//
// Added in OPM
// Off, the world stops responding to dynamic lights entirely: no atlas
// block for any surface, so tess.dlightMap stays 0 and the texcoords are
// never shifted. Lights still exist and still light models and sprites -
// only the world is left alone.
//
// This is a workaround, not a feature. A muzzle flash currently makes the
// ground four times *darker* rather than lighting it, and the cause is
// not found - see docs/markdown/04-coding/05-dlights.md for what was
// measured and what is ruled out. Losing the flash lighting the scenery
// is a smaller loss than the ground going black every time a shot is
// fired, so it is off on the device until this is understood.
//
if (!r_worldDlights->integer) {
srf->drawinfo.dlightMap[tr.smpFrame] = 0;
return 0;
}
dli.numLights = 0;
for (i = 0; i < tr.refdef.num_dlights; i++) {
@@ -1471,8 +1528,68 @@ void R_ClearRealDlights()
{
memset(dli.allocated, 0, sizeof(dli.allocated));
dli.dlightMap = 0;
//
// Added in OPM
// See R_TraceDlight. At 2 the whole atlas is stained green before the
// front end writes anything into it, and every texel a light reaches is
// then painted magenta over the top. Three outcomes, and each names a
// different half of the pipe:
//
// magenta on the walls - the patch, the upload and the sampling all
// work, and the black is whatever R_DlightSample
// computes for real;
// green on the walls - the atlas reaches the surface, but the surface
// reads a part of it nothing was written to, so
// the fault is in the texcoords or the offset;
// still black - the atlas is not reaching the surface at all,
// however bright it is, so the fault is the
// upload or the bind.
//
// White, not a colour. A dark green stain was tried first and was a bad
// choice in a level made of hedgerows - it could not be told apart from
// foliage by eye, and on the device the areas in question turned out to
// have R, G and B all roughly equal, which a modulate against a
// green-only stain cannot produce. Full white can only make a surface
// brighter, so anything reading the atlas outside a written patch becomes
// unmissable and nothing else in the frame looks like it.
//
if (vr_traceDlight && vr_traceDlight->integer >= 2) {
int i;
for (i = 0; i < LIGHTMAP_SIZE * LIGHTMAP_SIZE; i++) {
dli.lightmap_buffer[i * 4 + 0] = 255;
dli.lightmap_buffer[i * 4 + 1] = 255;
dli.lightmap_buffer[i * 4 + 2] = 255;
dli.lightmap_buffer[i * 4 + 3] = 255;
}
}
}
/*
===============
R_TraceDlight
Added in OPM, for the port.
Muzzle flashes and explosions light the world by rewriting its lightmap, not by
adding a pass over it - see R_UploadDlights below. So when they come out black
on the surrounding surfaces there are only two places the black can be coming
from: the patch this builds on the CPU, or what the GPU does with it.
That is what this splits.
1 reports what was actually uploaded. On the device that came back bright - peak
548 of 765, over half the texels lit, no GL error - which rules the whole CPU
half out and says the black is downstream of it.
2 stains the atlas instead of measuring it, and is read off the walls rather
than out of the log: see R_ClearRealDlights for what each colour means.
===============
*/
cvar_t *vr_traceDlight;
cvar_t *r_worldDlights;
/*
===============
R_UploadDlights
@@ -1482,10 +1599,24 @@ void R_UploadDlights()
{
int i, h;
if (!tr.pc.c_dlightSurfaces) {
return;
}
//
// Changed in OPM
// There was a "if (!tr.pc.c_dlightSurfaces) return;" here. It is gone
// because the height below is the same guard done properly: it comes from
// dli.allocated, this allocator's own state, cleared per scene by
// R_ClearRealDlights and again at the end of this function, and it is zero
// exactly when the front end allocated nothing. tr.pc is the r_speeds
// counter block, which R_PerformanceCounters clears on its own schedule -
// nothing this depends on should be reading it.
//
// Recorded because it is a tempting wrong answer: that clear happens in
// R_IssueRenderCommands *before* the command list runs, which reads like
// it must leave the count at zero by the time RB_BeginDrawingView gets
// here, and therefore like the atlas is never uploaded at all. It is not
// what happens. Measured on the device, this is reached with the count at
// 35 surfaces and the patch bright, so the guard was passing and was never
// the reason muzzle flashes come out black.
//
h = 0;
for (i = 0; i < LIGHTMAP_SIZE; i++) {
if (h < dli.allocated[i]) {
@@ -1501,6 +1632,77 @@ void R_UploadDlights()
GL_Bind(tr.dlightImages[dli.dlightMap]);
qglTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, LIGHTMAP_SIZE, h, GL_RGBA, GL_UNSIGNED_BYTE, dli.lightmap_buffer);
if (vr_traceDlight && vr_traceDlight->integer) {
//
// Added in OPM
// This is the one moment in the frame that is known to have a
// flash in it. VR_CaptureEye is otherwise on a two second timer
// and has missed every flash it has been asked to catch, because
// a muzzle flash lasts a frame or two. This tells it to take the
// picture now. See VR_CaptureEye in vr_openxr.c.
//
ri.Cvar_Set("vr_flashFrame", "1");
//
// Added in OPM
// Read after the call and not before, so it belongs to this
// upload. GL_RGBA into a texture whose internal format came out
// GL_RGB is legal on the desktop and is not on GL ES, and a
// rejected sub-upload leaves the atlas holding whatever it had -
// which is the difference between "the patch is wrong" and "the
// patch never arrived", and cannot be told apart by looking.
//
const GLenum err = qglGetError();
int bright = 0;
int peak = 0;
int x, y;
for (y = 0; y < h; y++) {
const byte *row = &dli.lightmap_buffer[y * LIGHTMAP_SIZE * 4];
for (x = 0; x < LIGHTMAP_SIZE; x++) {
const int lum = row[x * 4 + 0] + row[x * 4 + 1] + row[x * 4 + 2];
if (lum > peak) {
peak = lum;
}
if (lum > 96) {
bright++;
}
}
}
ri.Printf(
PRINT_ALL,
"dlight: page %d rows %d surfaces %d texels %d | patch peak %d bright %d of %d |"
" internal 0x%x err 0x%x | overbright shift %d mult %.3f identity %.3f/%d\n",
dli.dlightMap,
h,
tr.pc.c_dlightSurfaces,
tr.pc.c_dlightTexels,
peak,
bright,
h * LIGHTMAP_SIZE,
tr.dlightImages[dli.dlightMap]->internalFormat,
(unsigned int)err,
//
// Added in OPM
// The dynamic patch is built from the raw BSP lightmap bytes
// and shifted by overbrightShift, while the static lightmap it
// replaces was scaled by identityLight when it was uploaded.
// Those are two different scales for the same quantity, and a
// surface that swaps one for the other changes brightness by
// the ratio between them - which is what the device shows,
// the lit area coming out three to four times darker rather
// than black.
//
tr.overbrightShift,
tr.overbrightMult,
tr.identityLight,
tr.identityLightByte
);
}
tr.pc.c_dlightMaps++;
memset(dli.allocated, 0, sizeof(dli.allocated));
}
@@ -1604,6 +1806,19 @@ qboolean R_DlightSample(byte *src, const vec3_t vec, byte *dst)
dst[2] = (byte)b;
dst[3] = -1;
//
// Added in OPM
// Only the texels a light actually reached, so the magenta traces the
// light's own footprint rather than the whole patch. Deliberately after
// the real write and not instead of it: everything above still runs, so
// this cannot change which surfaces get a dlight map or how big it is.
//
if (added && vr_traceDlight && vr_traceDlight->integer >= 2) {
dst[0] = 255;
dst[1] = 0;
dst[2] = 255;
}
return added;
}
+66
View File
@@ -49,8 +49,24 @@ QGL_3_0_PROCS;
extern "C" {
#endif
#ifdef USE_GL4ES
// 16 bit indices on OpenGL ES, which is what gl4es is running on underneath.
// ES 2.0 has no 32 bit element indices at all without GL_OES_element_index_uint,
// so glDrawElements with GL_UNSIGNED_INT draws nothing and reports nothing.
// Immediate mode is unaffected - gl4es collects glBegin/glEnd itself and picks
// its own index type - which is why a build with this wrong renders cinematics
// and the loading screen perfectly while every menu, every HUD element and the
// entire world are missing.
//
// Costs nothing here. Indices only ever address within one tess batch, and
// SHADER_MAX_VERTEXES is 2048; RTCWQuest runs the same renderer at 6000 with
// shorts (their tr_local.h, under HAVE_GLES).
#define GL_INDEX_TYPE GL_UNSIGNED_SHORT
typedef unsigned short glIndex_t;
#else
#define GL_INDEX_TYPE GL_UNSIGNED_INT
typedef unsigned int glIndex_t;
#endif
// fast float to int conversion
#if id386 && !( (defined __linux__ || defined __FreeBSD__ ) && (defined __i386__ ) ) // rb010123
@@ -1409,6 +1425,33 @@ typedef struct {
int farclip;
} trGlobals_t;
/*
The eye currently being rendered, when driven by a headset.
Kept outside trGlobals_t for the same reason as glState: it describes the
display rather than the scene, and must survive a renderer re-init.
*/
typedef struct {
qboolean active;
vec3_t origin; // head offset from the game's camera, engine units
vec3_t axis[3]; // head orientation as forward/left/up
float tanLeft; // asymmetric frustum edges at unit distance
float tanRight;
float tanUp;
float tanDown;
float baseYaw; // head heading the game's camera already carries
int eye; // which eye, for the HUD's stereo offset
} vrViewState_t;
extern vrViewState_t vrView;
void RE_SetVRView( const float *origin, const vec3_t *axis,
float tanLeft, float tanRight, float tanUp, float tanDown,
float baseYaw, int eye );
void RE_SetDefaultFramebuffer( unsigned int framebuffer );
void R_ApplyVRView( refdef_t *fd );
void R_VRAdjust2DRect( int *x, int *y, int *w, int *h );
extern backEndState_t backEnd;
extern trGlobals_t tr;
extern glconfig_t glConfig; // outside of TR since it shouldn't be cleared during ref re-init
@@ -1451,6 +1494,13 @@ extern cvar_t *r_texturebits; // number of desired texture bits
extern cvar_t *r_measureOverdraw; // enables stencil buffer overdraw measurement
extern cvar_t *r_vrTrace; // log the composed VR camera once a second
extern cvar_t *vr_fovZoom; // scope magnification, published by cgame
extern cvar_t *vr_hudScale; // how much of the display the HUD covers
extern cvar_t *vr_hudDepth; // metres out the HUD is made to converge
extern cvar_t *vr_hudOffset; // share of the optical-axis centring to apply
extern cvar_t *vr_traceHud; // report the 2D correction once per eye
extern cvar_t *r_lodscale;
extern cvar_t *r_primitives; // "0" = based on compiled vertex array existance
@@ -1464,6 +1514,22 @@ extern cvar_t *r_fastsky; // controls whether sky should be cleared or drawn
extern cvar_t *r_fastdlights;
extern cvar_t *r_drawSun; // controls drawing of sun quad
extern cvar_t *r_dlightBacks; // dlight non-facing surfaces for continuity
// Added in OPM
// Reports what a muzzle flash or an explosion actually wrote into the dynamic
// lightmap, and at 2 paints it magenta. See R_TraceDlight in tr_light.c. Named
// with the port's other trace switches rather than r_, because it is turned on
// and off in misc/android/autoexec.cfg alongside them.
extern cvar_t *vr_traceDlight;
// Added in OPM
// Whether dynamic lights are allowed to rewrite the world's lightmap at all.
// A workaround switch, not a feature - see the comment in R_RealDlightFace
// and docs/markdown/04-coding/05-dlights.md.
extern cvar_t *r_worldDlights;
// Added in OPM
// One line per font shader loaded, naming what the UI text is actually drawn
// with. See R_LoadFontShader in tr_font.cpp.
extern cvar_t *vr_traceFont;
void RB_TraceDlightBind(const shader_t *shader, const char *what);
extern cvar_t *r_norefresh; // bypasses the ref rendering
extern cvar_t *r_drawentities; // disable/enable entity rendering
+82 -6
View File
@@ -569,11 +569,40 @@ void R_SetupProjection( void ) {
zNear = r_znear->value;
zFar = tr.viewParms.zFar;
ymax = zNear * tan( tr.refdef.fov_y * M_PI / 360.0f );
ymin = -ymax;
if ( vrView.active ) {
// A headset's per-eye frustum is not centred on the view axis - each
// eye sees further towards its own side than towards the nose. Using a
// symmetric approximation misaligns the two images by a few degrees,
// which reads as an image the eyes cannot fuse. The matrix built below
// already carries the off-centre terms in its [8] and [9] elements, so
// the runtime's own edges go straight in.
//
// Scopes and binoculars. The game narrows its fov to zoom, but in a
// headset the frustum comes from the runtime, so that request would
// otherwise be dropped on the floor. Narrowing the eye frustum by the
// same factor while the composition layer keeps claiming the headset's
// full field of view leaves the compositor to magnify the result, which
// is both free and sharper than zooming after the fact: the narrow view
// was rendered into the whole eye texture.
float zoom = vr_fovZoom ? vr_fovZoom->value : 1.0f;
xmax = zNear * tan( tr.refdef.fov_x * M_PI / 360.0f );
xmin = -xmax;
if ( zoom < 1.0f ) {
zoom = 1.0f;
} else if ( zoom > 16.0f ) {
zoom = 16.0f;
}
xmin = zNear * vrView.tanLeft / zoom;
xmax = zNear * vrView.tanRight / zoom;
ymin = zNear * vrView.tanDown / zoom;
ymax = zNear * vrView.tanUp / zoom;
} else {
ymax = zNear * tan( tr.refdef.fov_y * M_PI / 360.0f );
ymin = -ymax;
xmax = zNear * tan( tr.refdef.fov_x * M_PI / 360.0f );
xmin = -xmax;
}
width = xmax - xmin;
height = ymax - ymin;
@@ -612,6 +641,47 @@ void R_SetupFrustum (void) {
float xs, xc;
float ang;
if ( vrView.active ) {
// Cull against the frustum actually being rendered, not the one the
// game asked for.
//
// R_SetupProjection already builds the projection from the runtime's
// four tangents, which are both wider than the game's fov and not
// centred on the view axis - each eye sees further towards its own side
// than towards the nose. The planes below were derived from
// viewParms.fovX/fovY instead, which is the game's symmetric cone, so
// everything between the two was culled while still being inside the
// picture: geometry disappearing towards the outer edge of each eye,
// and whole surfaces at once, since culling is per surface. Large flat
// ones - terrain patches - go all or nothing.
//
// renderergl2 does not have this bug, which is why it never showed up
// while that was the renderer: its R_SetupFrustum takes xmin/xmax/ymax
// from the projection and handles the asymmetric case explicitly.
//
// Q3's axes are forward, left, up. A boundary ray on the right edge is
// forward - tanRight*left, and the inward normal of the plane holding it
// is tanRight*forward + left; the other three follow the same way. Not
// normalised on purpose - R_CullLocalBox and friends only ever test the
// sign of the dot product against dist, and dist is built from the same
// unnormalised normal below.
VectorScale( tr.viewParms.ori.axis[0], vrView.tanRight, tr.viewParms.frustum[0].normal );
VectorMA( tr.viewParms.frustum[0].normal, 1.0f, tr.viewParms.ori.axis[1], tr.viewParms.frustum[0].normal );
VectorNormalize( tr.viewParms.frustum[0].normal );
VectorScale( tr.viewParms.ori.axis[0], -vrView.tanLeft, tr.viewParms.frustum[1].normal );
VectorMA( tr.viewParms.frustum[1].normal, -1.0f, tr.viewParms.ori.axis[1], tr.viewParms.frustum[1].normal );
VectorNormalize( tr.viewParms.frustum[1].normal );
VectorScale( tr.viewParms.ori.axis[0], vrView.tanUp, tr.viewParms.frustum[2].normal );
VectorMA( tr.viewParms.frustum[2].normal, -1.0f, tr.viewParms.ori.axis[2], tr.viewParms.frustum[2].normal );
VectorNormalize( tr.viewParms.frustum[2].normal );
VectorScale( tr.viewParms.ori.axis[0], -vrView.tanDown, tr.viewParms.frustum[3].normal );
VectorMA( tr.viewParms.frustum[3].normal, 1.0f, tr.viewParms.ori.axis[2], tr.viewParms.frustum[3].normal );
VectorNormalize( tr.viewParms.frustum[3].normal );
} else {
ang = tr.viewParms.fovX / 180 * M_PI * 0.5f;
xs = sin( ang );
xc = cos( ang );
@@ -632,6 +702,8 @@ void R_SetupFrustum (void) {
VectorScale( tr.viewParms.ori.axis[0], xs, tr.viewParms.frustum[3].normal );
VectorMA( tr.viewParms.frustum[3].normal, -xc, tr.viewParms.ori.axis[2], tr.viewParms.frustum[3].normal );
}
for (i=0 ; i<4 ; i++) {
tr.viewParms.frustum[i].type = PLANE_NON_AXIAL;
tr.viewParms.frustum[i].dist = DotProduct (tr.viewParms.ori.origin, tr.viewParms.frustum[i].normal);
@@ -1448,10 +1520,14 @@ void R_AddSpriteSurfaces()
tr.currentEntityNum = ENTITYNUM_WORLD;
tr.shiftedEntityNum = tr.currentEntityNum << QSORT_ENTITYNUM_SHIFT;
if (sprite->hModel && sprite->hModel < tr.numModels) {
if (sprite->hModel > 0 && sprite->hModel < tr.numModels) {
tr.currentModel = &tr.models[sprite->hModel];
} else {
tr.currentModel = &tr.models[0];
continue;
}
if (tr.currentModel->type != MOD_SPRITE || !tr.currentModel->d.sprite) {
continue;
}
R_AdjustVisBoundsForSprite(&tr.refdef.sprites[tr.currentSpriteNum], &tr.viewParms, &tr.ori );
+10
View File
@@ -442,6 +442,7 @@ to handle mirrors,
void RE_RenderScene( const refdef_t *fd ) {
viewParms_t parms;
int startTime;
refdef_t vrRefdef;
if ( !tr.registered ) {
return;
@@ -452,6 +453,15 @@ void RE_RenderScene( const refdef_t *fd ) {
return;
}
// Every camera the game has arrives here, so composing the head pose on at
// this point is what makes head tracking follow cutscenes and ladders as
// well as the player's own view. See R_ApplyVRView.
if ( vrView.active ) {
vrRefdef = *fd;
R_ApplyVRView( &vrRefdef );
fd = &vrRefdef;
}
startTime = ri.Milliseconds();
if (!tr.world && !( fd->rdflags & RDF_NOWORLDMODEL ) ) {
+114 -3
View File
@@ -167,16 +167,34 @@ static void R_DrawElements( int numIndexes, const glIndex_t *indexes ) {
// default is to use triangles if compiled vertex arrays are present
if ( primitives == 0 ) {
#ifdef USE_GL4ES
// glDrawElements, always, which is what the reference does: RTCWQuest
// compiles R_DrawStripElements and R_ArrayElementDiscrete out of this
// file entirely under HAVE_GLES and sends every surface through the one
// call.
//
// This is not a preference, it is the only route on this stack that is
// known to work. The other two rebuild each strip as
// glBegin/glArrayElement/glEnd, which under gl4es is its immediate mode
// emulation reading back out of the client arrays - vertices at stride
// 16, colours as packed bytes, texcoords from a third array - and the
// working reference has never once exercised it.
//
// This port was on that route by accident rather than by choice:
// compiled vertex arrays were switched off (see GLimp_InitExtensions),
// so qglLockArraysEXT is NULL, so this fell through to 1.
primitives = 2;
#else
if ( qglLockArraysEXT ) {
primitives = 2;
} else {
primitives = 1;
}
#endif
}
if ( primitives == 2 ) {
qglDrawElements( GL_TRIANGLES,
qglDrawElements( GL_TRIANGLES,
numIndexes,
GL_INDEX_TYPE,
indexes );
@@ -359,6 +377,55 @@ because a surface may be forced to perform a RB_End due
to overflow.
==============
*/
/*
===================
RB_TraceDlightBind
Added in OPM, for the port. See R_TraceDlight in tr_light.c.
The lightmap texcoords are rewritten into the dynamic atlas's frame whenever
tess.dlightMap is set - RB_SurfaceFace and RB_SurfaceGrid branch on that alone.
The atlas is only *bound* in its place if the lightmap is in the particular
bundle each stage iterator happens to look at. Where the two disagree, the
surface reads the real lightmap atlas at dynamic atlas coordinates: it lands in
the parts of it no lightmap was packed into, and those are black.
That is the shape of what the device shows - large black areas beside small
correct ones, when the atlas itself is measured bright and, stained, comes back
as magenta and green in exactly the places that are not black.
Per batch and not per stage: a single stage binding its own texture is normal,
and only a batch that finishes without any stage having bound the atlas is
wrong. One line per shader per run, because the list of names is the finding.
===================
*/
static qboolean rb_dlightBound;
void RB_TraceDlightBind(const shader_t *shader, const char *what)
{
static const shader_t *seen[24];
static int numSeen;
int i;
if (!vr_traceDlight || !vr_traceDlight->integer || !shader) {
return;
}
for (i = 0; i < numSeen; i++) {
if (seen[i] == shader) {
return;
}
}
if (numSeen >= (int)(sizeof(seen) / sizeof(seen[0]))) {
return;
}
seen[numSeen++] = shader;
ri.Printf(PRINT_ALL, "dlight: %s - \"%s\" (%d stages, lightmapIndex %d)\n",
what, shader->name, shader->numUnfoggedPasses, shader->lightmapIndex);
}
void RB_BeginSurface( shader_t *shader ) {
tess.numIndexes = 0;
tess.numVertexes = 0;
@@ -369,6 +436,8 @@ void RB_BeginSurface( shader_t *shader ) {
tess.numPasses = shader->numUnfoggedPasses;
tess.currentStageIteratorFunc = shader->optimalStageIteratorFunc;
// Added in OPM - see RB_TraceDlightBind.
rb_dlightBound = qfalse;
}
/*
@@ -472,6 +541,7 @@ static void DrawMultitextured( shaderCommands_t *input, int stage ) {
R_BindAnimatedImage(&pStage->bundle[1]);
} else {
GL_Bind(tr.dlightImages[input->dlightMap - 1]);
rb_dlightBound = qtrue; // Added in OPM - see RB_TraceDlightBind
}
R_DrawElements( input->numIndexes, input->indexes );
@@ -517,7 +587,11 @@ static void ProjectDlightTexture( void ) {
byte clipBits[SHADER_MAX_VERTEXES];
MAC_STATIC float texCoordsArray[SHADER_MAX_VERTEXES][2];
byte colorArray[SHADER_MAX_VERTEXES][4];
unsigned hitIndexes[SHADER_MAX_INDEXES];
// glIndex_t, for the same reason as tessIndexes in RB_SurfaceFace: this is
// handed straight to R_DrawElements, so it has to be the type GL is told
// the indices are. As unsigned it was read two indices at a time under
// gl4es, which put the dynamic lights on the wrong triangles.
glIndex_t hitIndexes[SHADER_MAX_INDEXES];
int numIndexes;
int planetype;
float scale;
@@ -529,6 +603,32 @@ static void ProjectDlightTexture( void ) {
return;
}
//
// Added in OPM, for the port. See R_TraceDlight in tr_light.c.
//
// This is the other dynamic light path, and the one nothing has looked at
// yet: Quake 3's, a second additive pass over the surface, used for every
// surface the MOHAA atlas did not give a block to - allocation refused, no
// light actually landing on a lumel, or a shader with no lightmap at all,
// which in the Bocage is most of the foliage.
//
// It draws the radial falloff texture, which is black at its edges, and it
// is only not black on screen because the blend makes black contribute
// nothing. If the blend is not in force, those triangles go opaque black -
// which is the shape on the device, and the atlas path has now been
// measured correct end to end, so this is what is left.
//
// 3 skips it. If the black goes with it, it is this pass; if it stays,
// it is not, and that is worth as much.
//
if (vr_traceDlight && vr_traceDlight->integer >= 3) {
return;
}
if (vr_traceDlight && vr_traceDlight->integer) {
RB_TraceDlightBind(tess.shader, "drawn by the Quake 3 dlight pass, not the atlas");
}
#if idppc_altivec
// There has to be a better way to do this so that floatColor
// and/or modulate are already 16-byte aligned.
@@ -1492,6 +1592,7 @@ static void RB_IterateStagesGeneric( shaderCommands_t *input )
else {
if (input->dlightMap && tess.xstages[stage]->bundle[0].isLightmap) {
GL_Bind(tr.dlightImages[input->dlightMap - 1]);
rb_dlightBound = qtrue; // Added in OPM - see RB_TraceDlightBind
}
else {
R_BindAnimatedImage(&pStage->bundle[0]);
@@ -1770,6 +1871,7 @@ void RB_StageIteratorLightmappedMultitextureUnfogged( void ) {
}
if (tess.dlightMap && tess.xstages[0]->bundle[1].isLightmap) {
GL_Bind(tr.dlightImages[tess.dlightMap - 1]);
rb_dlightBound = qtrue; // Added in OPM - see RB_TraceDlightBind
} else {
R_BindAnimatedImage(&tess.xstages[0]->bundle[1]);
}
@@ -1859,6 +1961,15 @@ void RB_EndSurface( void ) {
//
tess.currentStageIteratorFunc();
//
// Added in OPM
// The texcoords went into the dynamic atlas's frame and nothing put the
// atlas under them. See RB_TraceDlightBind.
//
if (tess.dlightMap && !rb_dlightBound) {
RB_TraceDlightBind(tess.shader, "texcoords shifted, atlas never bound");
}
if (!(tr.refdef.rdflags & RDF_NOWORLDMODEL) && !backEnd.in2D)
{
//
+68 -6
View File
@@ -595,12 +595,25 @@ static void AutospriteDeform(void) {
tess.numVertexes = 0;
tess.numIndexes = 0;
if (backEnd.currentEntity != &tr.worldEntity) {
GlobalVectorToLocal(backEnd.viewParms.ori.axis[1], leftDir);
GlobalVectorToLocal(backEnd.viewParms.ori.axis[2], upDir);
} else {
VectorCopy(backEnd.viewParms.ori.axis[1], leftDir);
VectorCopy(backEnd.viewParms.ori.axis[2], upDir);
{
vec3_t viewLeft, viewUp;
VectorCopy(backEnd.viewParms.ori.axis[1], viewLeft);
VectorCopy(backEnd.viewParms.ori.axis[2], viewUp);
if (vrView.active) {
// World up, so the sprites stay upright rather than inheriting the
// head's roll.
VectorSet(viewUp, 0.0f, 0.0f, 1.0f);
}
if (backEnd.currentEntity != &tr.worldEntity) {
GlobalVectorToLocal(viewLeft, leftDir);
GlobalVectorToLocal(viewUp, upDir);
} else {
VectorCopy(viewLeft, leftDir);
VectorCopy(viewUp, upDir);
}
}
for (i = 0; i < oldVerts; i += 4) {
@@ -614,9 +627,37 @@ static void AutospriteDeform(void) {
VectorSubtract(xyz, mid, delta);
radius = VectorLength(delta) * 0.707f; // / sqrt(2)
// Quake's autosprites align to the view plane: every sprite takes the
// camera's own axes, so they all sit parallel to the screen. On a
// monitor that is indistinguishable from facing the viewer, but in VR
// turning the head swings the whole set round to stay parallel to the
// view plane, and they visibly spin in place. Point each one at where
// the viewer actually is instead, so head rotation leaves them alone
// and only moving changes what they present.
if (vrView.active) {
vec3_t toViewer, spriteLeft, spriteUp;
VectorSubtract(backEnd.ori.viewOrigin, mid, toViewer);
CrossProduct(upDir, toViewer, spriteLeft);
// Degenerate directly above or below the sprite, where there is no
// heading to align to; the shared axes are as good as anything.
if (VectorNormalize(spriteLeft) > 0.001f) {
CrossProduct(toViewer, spriteLeft, spriteUp);
if (VectorNormalize(spriteUp) > 0.001f) {
VectorScale(spriteLeft, radius, left);
VectorScale(spriteUp, radius, up);
goto haveAxes;
}
}
}
VectorScale(leftDir, radius, left);
VectorScale(upDir, radius, up);
haveAxes:
if (backEnd.viewParms.isMirror) {
VectorSubtract(vec3_origin, left, left);
}
@@ -748,6 +789,27 @@ static void Autosprite2Deform( void ) {
VectorSubtract( mid[1], mid[0], major );
// cross this with the view direction to get minor axis
if ( vrView.active ) {
// Towards the viewer, not along the view axis. On a monitor the two
// amount to the same thing - the eye is effectively infinitely far
// away down the middle of the screen - but in a headset they are
// not, and taking the view axis swings every one of these sprites
// as the head turns while the geometry it stands beside holds
// still. That is the second, worse tree leaning out of every real
// one: the imposter and the model disagreeing about where the
// viewer is.
//
// Per sprite rather than per batch for the same reason: each one
// has to face its own line to the eye, and a single direction for
// the whole surface is only right for a sprite dead ahead.
vec3_t centre;
VectorAdd( mid[0], mid[1], centre );
VectorScale( centre, 0.5f, centre );
VectorSubtract( centre, backEnd.ori.viewOrigin, forward );
VectorNormalize( forward );
}
CrossProduct( major, forward, minor );
VectorNormalize( minor );
+64 -15
View File
@@ -61,13 +61,13 @@ static int CullSprite(const vec3_t* points) {
for (i = 0; i < backEnd.viewParms.fog.extrafrustums + 4; i++) {
for (j = 0; j < 4; j++) {
if (DotProduct(points[j], backEnd.viewParms.frustum[i].normal) - backEnd.viewParms.frustum[i].dist > 0.0f) {
if (DotProduct(points[j], backEnd.viewParms.frustum[i].normal) >= backEnd.viewParms.frustum[i].dist) {
break;
}
}
if (j == 4) {
return CULL_CLIP;
return CULL_OUT;
}
}
@@ -87,15 +87,15 @@ void RB_DrawSprite( const refSprite_t *spr ) {
float org_x, org_y;
float scale;
if (!spr->hModel)
if (spr->hModel <= 0 || spr->hModel >= tr.numModels)
{
ri.Printf(PRINT_WARNING, "No model found for Sprite\n");
return;
}
model = tr.models;
if (spr->hModel > 0 && spr->hModel < tr.numModels) {
model = &tr.models[spr->hModel];
model = &tr.models[spr->hModel];
if (model->type != MOD_SPRITE || !model->d.sprite) {
return;
}
scale = spr->scale * model->d.sprite->scale;
@@ -108,10 +108,16 @@ void RB_DrawSprite( const refSprite_t *spr ) {
float cr, sr;
int i;
invmag = 1.0 / sqrt(spr->axis[1][2] * spr->axis[1][2] + spr->axis[1][1] * spr->axis[1][1]);
cr = invmag * spr->axis[1][1];
sr = invmag * spr->axis[1][2];
invmag = spr->axis[1][2] * spr->axis[1][2] + spr->axis[1][1] * spr->axis[1][1];
if (invmag > 0.0f) {
invmag = 1.0f / sqrt(invmag);
cr = invmag * spr->axis[1][1];
sr = invmag * spr->axis[1][2];
} else {
// No projected roll axis: keep the quad finite and unrotated.
cr = 1.0f;
sr = 0.0f;
}
for (i = 0; i < 3; i++)
{
@@ -121,6 +127,28 @@ void RB_DrawSprite( const refSprite_t *spr ) {
}
break;
case SPRITE_PARALLEL:
// In VR, face where the viewer is rather than lying parallel to the
// view plane. Parallel to the view plane means every sprite in the
// world swings round together the moment the head turns, which is
// invisible on a monitor and glaring in a headset.
if (vrView.active) {
vec3_t toViewer;
VectorSubtract(backEnd.viewParms.ori.origin, spr->origin, toViewer);
if (VectorNormalize(toViewer) > 0.001f) {
vec3_t worldUp = { 0.0f, 0.0f, 1.0f };
CrossProduct(worldUp, toViewer, right);
if (VectorNormalize(right) > 0.001f) {
CrossProduct(toViewer, right, up);
VectorNormalize(up);
break;
}
}
}
VectorCopy(backEnd.viewParms.ori.axis[2], up);
if (!backEnd.viewParms.isMirror) {
VectorNegate(backEnd.viewParms.ori.axis[1], right);
@@ -136,15 +164,36 @@ void RB_DrawSprite( const refSprite_t *spr ) {
VectorCopy(backEnd.viewParms.ori.axis[0], norm);
VectorCopy(backEnd.viewParms.ori.axis[1], right);
VectorCopy(backEnd.viewParms.ori.axis[2], up);
if (backEnd.viewParms.ori.axis[0][2] > 0.999) {
return;
} else if (backEnd.viewParms.ori.axis[0][2] < -0.999) {
// VR facing comes from the viewer's position, so looking vertically
// does not make an otherwise visible upright sprite degenerate.
if (!vrView.active && fabs(backEnd.viewParms.ori.axis[0][2]) > 0.999f) {
return;
}
VectorSet(up, 0.0f, 0.0f, 1.0f);
VectorSet(right, backEnd.viewParms.ori.axis[0][1], -backEnd.viewParms.ori.axis[0][0], 0.0f);
VectorNormalize(right);
// Upright sprites already ignore the view's roll and pitch, but they
// still turn with the view's heading rather than towards the viewer -
// so in VR they spin in place as the head turns. Aim each one at where
// the viewer actually stands instead.
if (vrView.active) {
vec3_t toViewer;
VectorSubtract(backEnd.viewParms.ori.origin, spr->origin, toViewer);
toViewer[2] = 0.0f;
if (VectorNormalize(toViewer) > 0.001f) {
VectorSet(right, -toViewer[1], toViewer[0], 0.0f);
} else {
// Directly above/below: use a stable axis, even when the view
// direction is vertical too.
VectorSet(right, 1.0f, 0.0f, 0.0f);
}
} else {
VectorSet(right, backEnd.viewParms.ori.axis[0][1], -backEnd.viewParms.ori.axis[0][0], 0.0f);
VectorNormalize(right);
}
VectorSet(norm, -right[1], right[0], 0.0f);
break;
}
+105 -1
View File
@@ -375,10 +375,30 @@ void RB_SurfaceBeam( void )
RB_SurfaceFace
==============
*/
// Added in OPM - see the reports in the dlight branches of RB_SurfaceFace and
// RB_DrawTerrainTris. Capped rather than deduplicated: the spread across
// surfaces is the measurement. Restart to re-arm them.
static int rb_terrainSpans;
static int rb_faceSpans;
void RB_SurfaceFace( srfSurfaceFace_t *surf ) {
int i;
qboolean needsNormal;
unsigned *indices, *tessIndexes;
unsigned *indices;
// glIndex_t, not unsigned. These two are different types the moment
// GL_INDEX_TYPE is GL_UNSIGNED_SHORT, which it is under gl4es, and writing
// tess.indexes through an unsigned* puts every index four bytes apart in a
// two byte array. The engine's own indices come out interleaved with the
// high halves of the previous ones, so each triangle picks up whichever
// vertex that lands on - which is what dragged the world's geometry, and
// its textures with it, into long streaks while models rendered perfectly.
// Models never come through here.
//
// The compiler said so all along:
// incompatible pointer types assigning to 'unsigned int *' from
// 'glIndex_t *' (aka 'unsigned short *') [-Wincompatible-pointer-types]
// under sixty four other warnings in this file alone.
glIndex_t *tessIndexes;
float *v;
float *normal;
int ndx;
@@ -434,6 +454,41 @@ void RB_SurfaceFace( srfSurfaceFace_t *surf ) {
*(unsigned int*)&tess.vertexColors[ndx] = *(unsigned int*)&v[7];
tess.vertexDlightBits[ndx] = dlightBits;
}
//
// Added in OPM
// Which rows of the atlas this face's texcoords actually reach. Only
// the rows R_UploadDlights calls "rows N" are ever sent; past that the
// texture keeps what it had, which is black. So a face running beyond
// N is reading rows nothing wrote this frame - a black wall. The
// offset it is shifted by is printed too, because that is the term
// under suspicion. See RB_TraceDlightBind in tr_shade.c.
//
if (vr_traceDlight && vr_traceDlight->integer && rb_faceSpans < 24 && numPoints > 0) {
float sMin = 1.0e9f, sMax = -1.0e9f, tMin = 1.0e9f, tMax = -1.0e9f;
int n;
for (n = 0, ndx = tess.numVertexes; n < numPoints; n++, ndx++) {
const float s = tess.texCoords[ndx][1][0];
const float t = tess.texCoords[ndx][1][1];
if (s < sMin) { sMin = s; }
if (s > sMax) { sMax = s; }
if (t < tMin) { tMin = t; }
if (t > tMax) { tMax = t; }
}
rb_faceSpans++;
ri.Printf(PRINT_ALL,
"dlight: face %d rows %.1f..%.1f cols %.1f..%.1f of 128,"
" offset %.4f,%.4f lm %dx%d at %d,%d map %d\n",
rb_faceSpans,
tMin * LIGHTMAP_SIZE, tMax * LIGHTMAP_SIZE,
sMin * LIGHTMAP_SIZE, sMax * LIGHTMAP_SIZE,
surf->lightmapOffset[0], surf->lightmapOffset[1],
surf->lmWidth, surf->lmHeight, surf->lmX, surf->lmY,
tess.dlightMap);
}
}
else
{
@@ -817,9 +872,31 @@ void RB_DrawTerrainTris(srfTerrain_t* p) {
dlightBits = p->dlightBits[backEnd.smpFrame];
tess.dlightBits |= dlightBits;
//
// Added in OPM
// Terrain picks its texcoords off the patch's own flag while the atlas is
// bound off the batch's tess.dlightMap, which comes from the sort key.
// Those are two different pieces of state and nothing makes them agree, so
// this asks whether they do. See RB_TraceDlightBind in tr_shade.c.
//
if (p->dlightMap[backEnd.smpFrame] != tess.dlightMap) {
RB_TraceDlightBind(tess.shader, "terrain patch and batch disagree");
}
if (p->dlightMap[backEnd.smpFrame])
{
float lmScale = (1.0 / LIGHTMAP_SIZE) / p->lmapStep;
//
// Added in OPM
// Where in the atlas this patch's texcoords actually land. The atlas is
// 128 texels tall but only the rows R_UploadDlights reports as "rows N"
// are ever sent; past that the texture holds whatever it held last,
// which is black. So a patch whose t runs beyond N/128 is reading rows
// nothing wrote to this frame, and that is a black patch of ground.
// See RB_TraceDlightBind in tr_shade.c.
//
float sMin = 1.0e9f, sMax = -1.0e9f, tMin = 1.0e9f, tMax = -1.0e9f;
for (i = p->iVertHead; i; i = g_pVert[i].iNext) {
assert(tess.numVertexes < SHADER_MAX_VERTEXES);
@@ -829,6 +906,17 @@ void RB_DrawTerrainTris(srfTerrain_t* p) {
tess.texCoords[tess.numVertexes][0][1] = g_pVert[i].texCoords[0][1];
tess.texCoords[tess.numVertexes][1][0] = g_pVert[i].xyz[0] * lmScale + p->lmapX;
tess.texCoords[tess.numVertexes][1][1] = g_pVert[i].xyz[1] * lmScale + p->lmapY;
if (vr_traceDlight && vr_traceDlight->integer) {
const float s = tess.texCoords[tess.numVertexes][1][0];
const float t = tess.texCoords[tess.numVertexes][1][1];
if (s < sMin) { sMin = s; }
if (s > sMax) { sMax = s; }
if (t < tMin) { tMin = t; }
if (t > tMax) { tMax = t; }
}
tess.normal[tess.numVertexes][0] = 0;
tess.normal[tess.numVertexes][1] = 0;
tess.normal[tess.numVertexes][2] = 1.0;
@@ -840,6 +928,22 @@ void RB_DrawTerrainTris(srfTerrain_t* p) {
g_pVert[i].iVertArray = tess.numVertexes;
tess.numVertexes++;
}
// Added in OPM - see above. Capped rather than deduplicated by shader:
// the spread across patches is the measurement, and every one of them
// is the same shader. Restart to re-arm it.
if (vr_traceDlight && vr_traceDlight->integer && sMax >= sMin
&& rb_terrainSpans < 24) {
rb_terrainSpans++;
ri.Printf(PRINT_ALL,
"dlight: terrain patch %d rows %.1f..%.1f cols %.1f..%.1f of 128,"
" step %g size %d, batch map %d patch map %d\n",
rb_terrainSpans,
tMin * LIGHTMAP_SIZE, tMax * LIGHTMAP_SIZE,
sMin * LIGHTMAP_SIZE, sMax * LIGHTMAP_SIZE,
p->lmapStep, p->lmapSize,
tess.dlightMap, p->dlightMap[backEnd.smpFrame]);
}
}
else
{
+60 -2
View File
@@ -1179,6 +1179,53 @@ void R_TessellateTerrain()
R_DoTriMerging();
}
/*
================
R_TerrainViewFov
The horizontal field of view the terrain system should size itself against.
In VR this is not tr.refdef.fov_x. The projection comes from the runtime's four
tangents (R_SetupProjection) and the culling frustum is built from the same
(R_SetupFrustum), so refdef.fov_x is no longer what is on screen - it is
whatever the game happened to ask for, around 80 degrees, while a Quest 3 eye
sees closer to 100.
That matters here because terrain visibility is a cone test. R_MarkTerrainPatch
rejects any patch further off the view axis than g_fClipDotSquared allows, and
at fov 80 that half angle is about 46 degrees. Terrain inside the headset's view
but outside an 80 degree cone is therefore never marked visible, and never
drawn - silently, because nothing else in the VR path reads fov_x any more.
Taken from the widest of the two horizontal tangents rather than their sum: the
per-eye frustum is asymmetric, and the cone is symmetric about the view axis, so
it has to cover the wider side.
MOHAA specific. RTCWQuest has no terrain, so the reference cannot help here.
================
*/
static float R_TerrainViewFov(void)
{
float tanMax;
if (!vrView.active) {
return tr.refdef.fov_x;
}
tanMax = fabs(vrView.tanLeft);
if (fabs(vrView.tanRight) > tanMax) {
tanMax = fabs(vrView.tanRight);
}
if (tanMax < 0.01f) {
return tr.refdef.fov_x;
}
// Full angle, both sides of the axis.
return RAD2DEG(atan(tanMax)) * 2.0f;
}
/*
================
R_TerrainPrepareFrame
@@ -1208,7 +1255,7 @@ void R_TerrainPrepareFrame()
}
distance = 1.0;
fFov = tr.refdef.fov_x;
fFov = R_TerrainViewFov();
if (fFov < 1.0) {
fFov = 1.0;
}
@@ -1251,7 +1298,18 @@ void R_TerrainPrepareFrame()
g_fCheck = fCheck;
if (fDistBound != 0.0) {
index = (int)tr.refdef.fov_x;
// Clamped, and never zero. These are raw table indices: index 0 gives
// g_fDistanceTable[0] = 443.5 / sqrt(1 - 1), which is infinity, and an
// infinite distance poisons g_vClipOrigin below and culls every patch
// in the level. A fov of zero is not reachable today but nothing here
// checked, and the failure is total and silent.
index = (int)R_TerrainViewFov();
if (index < 1) {
index = 1;
} else if (index >= TERRAIN_TABLE_SIZE) {
index = TERRAIN_TABLE_SIZE - 1;
}
distance = g_fDistanceTable[index];
g_fClipDotSquared = g_fClipDotSquaredTable[index];
+414
View File
@@ -0,0 +1,414 @@
/*
===========================================================================
Copyright (C) 2025 the OpenMoHAA team
This file is part of OpenMoHAA source code.
OpenMoHAA source code is free software; you can redistribute it
and/or modify it under the terms of the GNU General Public License as
published by the Free Software Foundation; either version 2 of the License,
or (at your option) any later version.
OpenMoHAA source code is distributed in the hope that it will be
useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with OpenMoHAA source code; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
===========================================================================
*/
// tr_vr.c -- everything the fixed function renderer needs to know about a
// headset, which is deliberately very little. The VR layer owns the session,
// the poses and the swapchains; this is the surface where it meets the
// renderer.
#include "tr_local.h"
vrViewState_t vrView;
/*
============
RE_SetVRView
============
*/
void RE_SetVRView(
const float *origin,
const vec3_t *axis,
float tanLeft,
float tanRight,
float tanUp,
float tanDown,
float baseYaw,
int eye
)
{
if (!origin || !axis) {
vrView.active = qfalse;
return;
}
vrView.baseYaw = baseYaw;
vrView.eye = eye;
VectorCopy(origin, vrView.origin);
VectorCopy(axis[0], vrView.axis[0]);
VectorCopy(axis[1], vrView.axis[1]);
VectorCopy(axis[2], vrView.axis[2]);
vrView.tanLeft = tanLeft;
vrView.tanRight = tanRight;
vrView.tanUp = tanUp;
vrView.tanDown = tanDown;
vrView.active = qtrue;
}
/*
============
RE_SetDefaultFramebuffer
Called when the VR layer is about to move the target out from under the
renderer, so that anything still owed to the old one is paid first.
Deliberately does not bind anything. This renderer predates framebuffer objects,
has none of its own, and never touches the binding - so there is nothing here to
redirect, and the VR layer's own bind is what selects the eye.
Binding here would in fact be worse than useless. The renderer's GL goes through
gl4es, and gl4es can only bind framebuffers it created itself: its
glBindFramebuffer looks the name up in its own table, and for a name that came
from the driver's glGenFramebuffers - which is where every one of the VR layer's
framebuffers comes from - the lookup misses, it raises GL_INVALID_VALUE and
returns without binding. Worse, it leaves gl4es believing framebuffer 0 is still
current. That was a black headset with the frame loop running at a contented 90
fps, everything drawn neatly into the window nobody was looking at.
What is needed instead is the flush. gl4es batches geometry and issues it
lazily, so work built for one target would otherwise arrive in whichever is
bound when it finally goes out. Flushing while the old target is still bound is
what keeps one eye's contents out of the next.
============
*/
void RE_SetDefaultFramebuffer(unsigned int framebuffer)
{
if (qglFlush) {
qglFlush();
}
// Drawing into a framebuffer means something else is presenting it, so the
// window must not also be swapped - two presentation paths running at
// unrelated rates is what flicker looks like.
GLimp_SetPresentsToWindow(framebuffer == 0 ? qtrue : qfalse);
}
/*
====================
R_ApplyVRView
Puts the headset on top of whatever camera the game asked for.
Done in the renderer rather than the game because every camera reaches it
through RE_RenderScene - the player's view, cutscene cameras, ladder climbs,
death animations - so head tracking follows all of them without each having to
know about VR.
The game's camera decides where the body is and which way it faces. Everything
else comes from the headset: pitch and roll are taken from the head alone,
since blending them with the game's would fight the viewer's own neck.
====================
*/
void R_ApplyVRView(refdef_t *fd)
{
vec3_t baseAngles, bodyAngles, bodyAxis[3];
int i;
if (!vrView.active) {
return;
}
// Refuse a head pose that is not a rotation.
//
// These axes are written straight into refdef.viewaxis, and R_RotateForViewer
// copies them into the modelview without checking anything. A zeroed or
// degenerate set therefore produces a modelview whose determinant is zero -
// a matrix that collapses three dimensions into a plane - and everything
// drawn through it disappears while every individual number upstream of it
// still looks correct. Measured directly: world surfaces reporting
// "mvdet 0.000" in the same frame as others reporting 1.000.
//
// The game's own camera is a perfectly good fallback for a frame; a
// collapsed one is not.
{
const float len0 = VectorLength(vrView.axis[0]);
const float len1 = VectorLength(vrView.axis[1]);
const float len2 = VectorLength(vrView.axis[2]);
if (len0 < 0.5f || len1 < 0.5f || len2 < 0.5f) {
static int lastWarn;
int now = ri.Milliseconds();
if (now - lastWarn > 1000) {
lastWarn = now;
ri.Printf(
PRINT_WARNING,
"VR: degenerate head pose (axis lengths %.3f %.3f %.3f); keeping the game's camera\n",
len0,
len1,
len2
);
}
return;
}
}
// The game's camera decides where the body stands and which way it faces;
// the headset decides everything about where the eyes are relative to that.
// Only the heading is taken from the game - its pitch and roll would fight
// the viewer's own neck.
//
// Minus whatever heading the client already fed into the view angles. The
// game is told where the player is looking so that objectives and anything
// else consulting the view angles agree with what is in front of them, but
// that same heading then comes back through the player state and into this
// camera - and composing the head onto it again turns it twice. Yaw ends up
// doubled while pitch, which the game's camera does not carry here, does
// not, and half of the doubled yaw arrives a frame late through prediction.
// The result reads as a world that swims when the head turns.
//
// Taking it back off leaves the game's own contributions - recoil, lean,
// cutscene cameras, ladders - composed with the current headset pose, which
// is what this function is for.
vectoangles(fd->viewaxis[0], baseAngles);
VectorSet(bodyAngles, 0, baseAngles[YAW] - vrView.baseYaw, 0);
AnglesToAxis(bodyAngles, bodyAxis);
// Rotate the head's axes into the body's frame. Composed as a rotation
// rather than by adding Euler angles: adding them only happens to work
// while pitch and roll are small, and turns every head movement into a
// twist once they are not.
for (i = 0; i < 3; i++) {
vec3_t rotated;
VectorScale(bodyAxis[0], vrView.axis[i][0], rotated);
VectorMA(rotated, vrView.axis[i][1], bodyAxis[1], rotated);
VectorMA(rotated, vrView.axis[i][2], bodyAxis[2], rotated);
VectorCopy(rotated, fd->viewaxis[i]);
}
if (r_vrTrace && r_vrTrace->integer) {
static int lastTrace;
int now = ri.Milliseconds();
if (now - lastTrace > 1000) {
lastTrace = now;
ri.Printf(
PRINT_ALL,
"VR view: org %.0f %.0f %.0f body yaw %.1f | head off %.1f %.1f %.1f fwd %.2f %.2f %.2f\n",
fd->vieworg[0],
fd->vieworg[1],
fd->vieworg[2],
baseAngles[YAW],
vrView.origin[0],
vrView.origin[1],
vrView.origin[2],
vrView.axis[0][0],
vrView.axis[0][1],
vrView.axis[0][2]
);
}
}
// The head's offset within the play space, turned to face the same way the
// body does before it is added. Leaning left stays leaning left whichever
// way the player has turned.
VectorMA(fd->vieworg, vrView.origin[0], bodyAxis[0], fd->vieworg);
VectorMA(fd->vieworg, vrView.origin[1], bodyAxis[1], fd->vieworg);
VectorMA(fd->vieworg, vrView.origin[2], bodyAxis[2], fd->vieworg);
}
/*
================
R_VRGet2DTransform
The one correction flat content needs in a headset, as a scale about the centre
of the eye buffer and an offset in half-widths of it.
Three things are wrong with 2D in a headset that are not wrong on a screen. Each
eye looks through a frustum that is not centred on its own axis, so identical 2D
lands in a different place in each eye; identical 2D also has no disparity, so
whatever does line up sits at infinity rather than in front of the player; and
the edges of the display are outside what can comfortably be read.
Returns false when there is nothing to correct, which is every flat frame and
every pass that draws onto a compositor quad - the wrist panel and the screen
layer both have the compositor place them in the room, so applying an eye's
frustum to them would be doing the same job twice, oppositely.
================
*/
static qboolean R_VRGet2DTransform(float *scale, float *offX, float *offY)
{
float tanWidth, tanHeight;
float depth, parallax;
if (!vrView.active) {
return qfalse;
}
tanWidth = vrView.tanRight - vrView.tanLeft;
tanHeight = vrView.tanUp - vrView.tanDown;
// No frustum yet means nothing to centre on, and dividing by it would leave
// a NaN in the projection - which does not look like an error, it looks like
// the HUD simply not being there.
if (tanWidth < 0.0001f || tanHeight < 0.0001f) {
return qfalse;
}
*scale = vr_hudScale ? vr_hudScale->value : 1.0f;
depth = vr_hudDepth ? vr_hudDepth->value : 2.0f;
if (*scale < 0.1f) {
*scale = 0.1f;
} else if (*scale > 1.0f) {
*scale = 1.0f;
}
if (depth < 0.5f) {
depth = 0.5f;
}
// Where the eye's optical axis falls in its own buffer, as a share of half
// the buffer. Zero tangent is straight ahead; the runtime's frustum is
// asymmetric, so that is not the middle of the image.
*offX = -(vrView.tanRight + vrView.tanLeft) / tanWidth;
*offY = -(vrView.tanUp + vrView.tanDown) / tanHeight;
//
// Added in OPM
// How much of the frustum centring to apply. 1 puts the content on the
// optical axis, which is what makes the two eyes agree; 0 centres it on
// the buffer instead, which is wrong but symmetric.
//
// Kept as a knob because it is the term that decides fusion: it is equal
// and opposite in the two eyes, so an error here pulls the images apart
// rather than merely misplacing them. Anything other than 1 is a way of
// asking whether the tangents are being reported honestly, not a setting.
//
if (vr_hudOffset) {
*offX *= vr_hudOffset->value;
*offY *= vr_hudOffset->value;
}
// Half an interpupillary distance at the given range, as a share of the
// eye's half width, opposite ways round for the two eyes. This is what puts
// the HUD at that range instead of infinitely far away.
parallax = (2.0f * 0.032f) / (depth * tanWidth);
*offX += (vrView.eye == 0) ? parallax : -parallax;
//
// Added in OPM
// One line per eye, once. Convergence cannot be judged from a screenshot -
// the Quest's capture is a reprojected composite of one eye, so the two
// images that have to fuse are never both in it. These are the numbers
// that decide it, and the last of them is the whole answer: the disparity
// is how far apart the same pixel lands in the two eyes, and it is either
// the half-IPD the depth asks for or it is not.
//
{
static int reported;
if (vr_traceHud && vr_traceHud->integer) {
if (!(reported & (1 << vrView.eye))) {
reported |= 1 << vrView.eye;
ri.Printf(
PRINT_ALL,
"vr hud: eye %d tan L%.3f R%.3f U%.3f D%.3f | scale %.2f depth %.2f offsetCvar %.2f"
" | offX %.4f offY %.4f parallax %.4f | shift %.1f,%.1f px of %dx%d\n",
vrView.eye,
vrView.tanLeft, vrView.tanRight, vrView.tanUp, vrView.tanDown,
*scale, depth, vr_hudOffset ? vr_hudOffset->value : 1.0f,
*offX, *offY, parallax,
*offX * glConfig.vidWidth * 0.5f,
*offY * glConfig.vidHeight * 0.5f,
glConfig.vidWidth, glConfig.vidHeight
);
}
} else {
// Re-armed while it is off, so turning it on later still says
// something. Otherwise the one report is spent during startup.
reported = 0;
}
}
return qtrue;
}
/*
================
R_VRAdjust2DRect
Moves a 2D viewport or scissor rectangle to suit the eye it is about to be drawn
into. Coordinates are pixels in the eye buffer, GL's way up.
Expressed as a change to the rectangle rather than to the ortho window, and this
is the whole point of the function.
The correction is a property of the display: the same number of pixels, in the
same direction, for everything flat in the frame. An ortho window is not - in
this engine it is usually a single widget. UIWidget::set2D gives every widget a
viewport and an ortho window cut to that widget's own frame, and the HUD is
built out of widgets: hud_compass, hud_health, hud_ammo, hud_weapons, hud_items
are each a menu drawn that way. Correcting the window therefore scaled the
correction to the widget, and a hundred and fifty pixel compass in a sixteen
hundred pixel eye received about a twelfth of the shift it needed.
That is what the HUD refusing to fuse actually was. Every element ended up with
a disparity in proportion to its own width, so each one sat at a different
apparent depth - the objectives, drawn through the full screen window the 3D
view sets up, converged where they were asked to; the compass beside them sat
near infinity. There is no vergence a pair of eyes can adopt that resolves both,
so the HUD reads as doubled no matter what vr_hudDepth is set to.
Applied to the rectangle it comes out in pixels, which every 2D window shares,
so all of it converges together whatever window it was drawn through. It also
puts vr_hudScale back in charge of where things are rather than only how big
they are: scaling about the centre of the display draws the corners inwards,
where before each widget merely shrank where it stood.
================
*/
void R_VRAdjust2DRect(int *x, int *y, int *w, int *h)
{
float scale, offX, offY;
float halfW, halfH;
float x0, y0, x1, y1;
if (!R_VRGet2DTransform(&scale, &offX, &offY)) {
return;
}
halfW = glConfig.vidWidth * 0.5f;
halfH = glConfig.vidHeight * 0.5f;
// Both edges are mapped and then rounded, rather than rounding the origin
// and scaling the size, so that neighbouring rectangles stay neighbouring.
x0 = halfW + (*x - halfW) * scale + offX * halfW;
y0 = halfH + (*y - halfH) * scale + offY * halfH;
x1 = x0 + *w * scale;
y1 = y0 + *h * scale;
*x = (int)floor(x0 + 0.5f);
*y = (int)floor(y0 + 0.5f);
*w = (int)floor(x1 + 0.5f) - *x;
*h = (int)floor(y1 + 0.5f) - *y;
// A zero width viewport is undefined and a zero width scissor draws
// nothing; a widget small enough to round away is better drawn thin.
if (*w < 1) {
*w = 1;
}
if (*h < 1) {
*h = 1;
}
}
+117 -5
View File
@@ -669,11 +669,113 @@ RB_SetGL2D
================
*/
/*
================
R_VRAdjust2DRect
Moves a 2D viewport or scissor rectangle to suit the eye it is about to be drawn
into. Coordinates are pixels in the eye buffer, GL's way up.
Flat content in a headset needs three corrections that no screen ever does. Each
eye looks through a frustum that is not centred on its own axis, so identical 2D
lands in a different place in each eye; identical 2D also has no disparity, so
whatever does line up sits at infinity; and the edges of the display are outside
what can comfortably be read.
Expressed as a change to the rectangle rather than to the ortho window, because
the correction belongs to the display and the window usually does not. See the
long note on the same function in renderergl1/tr_vr.c: UIWidget::set2D gives
every widget an ortho window cut to its own frame, so correcting the window
scaled the correction to the widget and left each HUD element converging at a
different depth.
Lives here, and is called from both places that set up a 2D projection, because
Set2DWindow builds its own matrix rather than going through RB_SetGL2D - so
correcting only one of them corrects nothing that the UI actually draws.
================
*/
void R_VRAdjust2DRect( int *x, int *y, int *w, int *h )
{
float scale, depth;
float tanWidth, tanHeight;
float offX, offY, parallax;
float halfW, halfH;
float x0, y0, x1, y1;
if ( !vrView.active ) {
return;
}
tanWidth = vrView.tanRight - vrView.tanLeft;
tanHeight = vrView.tanUp - vrView.tanDown;
// No frustum yet means nothing to centre on, and dividing by it would leave
// a NaN in the projection - which does not look like an error, it looks like
// the HUD simply not being there.
if ( tanWidth < 0.0001f || tanHeight < 0.0001f ) {
return;
}
scale = vr_hudScale ? vr_hudScale->value : 1.0f;
depth = vr_hudDepth ? vr_hudDepth->value : 2.0f;
if ( scale < 0.1f ) {
scale = 0.1f;
} else if ( scale > 1.0f ) {
scale = 1.0f;
}
if ( depth < 0.5f ) {
depth = 0.5f;
}
// Where the eye's optical axis falls in its own buffer, as a share of half
// the buffer. Zero tangent is straight ahead; the runtime's frustum is
// asymmetric, so that is not the middle of the image.
offX = -( vrView.tanRight + vrView.tanLeft ) / tanWidth;
offY = -( vrView.tanUp + vrView.tanDown ) / tanHeight;
// Half an interpupillary distance at the given range, as a share of the
// eye's half width, opposite ways round for the two eyes. This is what puts
// the HUD at that range instead of infinitely far away.
parallax = ( 2.0f * 0.032f ) / ( depth * tanWidth );
offX += ( vrView.eye == 0 ) ? parallax : -parallax;
halfW = glConfig.vidWidth * 0.5f;
halfH = glConfig.vidHeight * 0.5f;
// Both edges are mapped and then rounded, rather than rounding the origin
// and scaling the size, so that neighbouring rectangles stay neighbouring.
x0 = halfW + ( *x - halfW ) * scale + offX * halfW;
y0 = halfH + ( *y - halfH ) * scale + offY * halfH;
x1 = x0 + *w * scale;
y1 = y0 + *h * scale;
*x = (int)floor( x0 + 0.5f );
*y = (int)floor( y0 + 0.5f );
*w = (int)floor( x1 + 0.5f ) - *x;
*h = (int)floor( y1 + 0.5f ) - *y;
// A zero width viewport is undefined and a zero width scissor draws
// nothing; a widget small enough to round away is better drawn thin.
if ( *w < 1 ) {
*w = 1;
}
if ( *h < 1 ) {
*h = 1;
}
}
void RB_SetGL2D (void) {
mat4_t matrix;
int width, height;
float left, right, bottom, top;
if (backEnd.projection2D && backEnd.last2DFBO == glState.currentFBO)
// The early out keys on the bound framebuffer, which on a build with the
// renderer's own framebuffer support switched off is always NULL - so once
// this has run it never runs again. Harmless when the 2D projection is a
// constant, and not harmless in VR, where it differs per eye.
if (backEnd.projection2D && backEnd.last2DFBO == glState.currentFBO
&& !vrView.active)
return;
backEnd.projection2D = qtrue;
@@ -691,10 +793,20 @@ void RB_SetGL2D (void) {
}
// set 2D virtual screen size
qglViewport( 0, 0, width, height );
qglScissor( 0, 0, width, height );
{
int vx = 0, vy = 0, vw = width, vh = height;
Mat4Ortho(0, width, height, 0, 0, 1, matrix);
R_VRAdjust2DRect( &vx, &vy, &vw, &vh );
qglViewport( vx, vy, vw, vh );
qglScissor( vx, vy, vw, vh );
}
left = 0;
right = width;
bottom = height;
top = 0;
Mat4Ortho(left, right, bottom, top, 0, 1, matrix);
GL_SetProjectionMatrix(matrix);
Mat4Identity(matrix);
GL_SetModelviewMatrix(matrix);
@@ -1934,7 +2046,7 @@ void RB_ExecuteRenderCommands( const void *data ) {
// stop rendering
t2 = ri.Milliseconds ();
backEnd.pc.msec = t2 - t1;
backEnd.pc.msec += t2 - t1;
return;
}
}
+4 -1
View File
@@ -116,7 +116,10 @@ void R_IssueRenderCommands( qboolean runPerformanceCounters ) {
cmdList->used = 0;
if ( runPerformanceCounters ) {
int backEndMsec = backEnd.pc.msec;
R_PerformanceCounters();
// EndFrame still needs the time spent in earlier command batches.
backEnd.pc.msec = backEndMsec;
}
// actually start the commands going
@@ -135,7 +138,7 @@ Issue any pending commands and wait for them to complete.
====================
*/
void R_IssuePendingRenderCommands( void ) {
if ( !tr.registered ) {
if ( !tr.registered || (!backEndData->commands.used && !tess.numIndexes) ) {
return;
}
R_IssueRenderCommands( qfalse );
+14
View File
@@ -504,6 +504,15 @@ void Set2DWindow(int x, int y, int w, int h, float left, float right, float bott
backEnd.projection2D = qtrue;
backEnd.last2DFBO = glState.currentFBO;
// Everything the UI draws comes through here rather than RB_SetGL2D, so
// this is where the headset's correction has to be applied or none of it
// reaches the HUD.
//
// Applied to the rectangle and not to the ortho window, because the window
// is usually a single widget's and the correction is the whole display's.
// See R_VRAdjust2DRect.
R_VRAdjust2DRect(&x, &y, &w, &h);
qglViewport(x, y, w, h);
qglScissor(x, y, w, h);
@@ -530,6 +539,11 @@ RE_Scissor
================
*/
void RE_Scissor(int x, int y, int width, int height) {
// The UI clips each widget to its own frame right after setting the window
// for it, so this has to move with the window or a widget the headset's
// correction has shifted is clipped away at where it used to be.
R_VRAdjust2DRect(&x, &y, &width, &height);
qglEnable(GL_SCISSOR_TEST);
qglScissor(x, y, width, height);
}
+27
View File
@@ -159,6 +159,33 @@ void GLimp_InitExtraExtensions(void)
ri.Printf(PRINT_ALL, result[2], extension);
}
// Framebuffer objects.
//
// These are core in OpenGL ES - the whole of
// QGL_ARB_framebuffer_object_PROCS is core in 3.0, with only blit and
// multisampled renderbuffers missing from 2.0. The desktop probing
// below is skipped entirely on ES, so without loading them here the
// renderer runs with framebuffers disabled and, worse, leaves every one
// of those entry points NULL for anything that calls them directly.
extension = "framebuffer object";
if (qglesMajorVersion >= 3)
{
glRefConfig.framebufferObject = !!r_ext_framebuffer_object->integer;
glRefConfig.framebufferBlit = qtrue;
glRefConfig.framebufferMultisample = qtrue;
qglGetIntegerv(GL_MAX_RENDERBUFFER_SIZE, &glRefConfig.maxRenderbufferSize);
qglGetIntegerv(GL_MAX_COLOR_ATTACHMENTS, &glRefConfig.maxColorAttachments);
QGL_ARB_framebuffer_object_PROCS;
ri.Printf(PRINT_ALL, result[glRefConfig.framebufferObject], extension);
}
else
{
ri.Printf(PRINT_ALL, result[2], extension);
}
goto done;
}
+68 -3
View File
@@ -239,7 +239,7 @@ void FBO_Bind(FBO_t * fbo)
GLimp_LogComment(va("--- FBO_Bind( %s ) ---\n", fbo ? fbo->name : "NULL"));
}
GL_BindFramebuffer(GL_FRAMEBUFFER, fbo ? fbo->frameBuffer : 0);
GL_BindFramebuffer(GL_FRAMEBUFFER, fbo ? fbo->frameBuffer : glState.defaultFBO);
glState.currentFBO = fbo;
}
@@ -416,7 +416,7 @@ void FBO_Init(void)
GL_CheckErrors();
GL_BindFramebuffer(GL_FRAMEBUFFER, 0);
GL_BindFramebuffer(GL_FRAMEBUFFER, glState.defaultFBO);
glState.currentFBO = NULL;
}
@@ -681,6 +681,71 @@ void FBO_FastBlit(const FBO_t *src, ivec4_t srcBox, FBO_t *dst, ivec4_t dstBox,
dstBoxFinal[0], dstBoxFinal[1], dstBoxFinal[2], dstBoxFinal[3],
buffers, filter);
GL_BindFramebuffer(GL_FRAMEBUFFER, 0);
GL_BindFramebuffer(GL_FRAMEBUFFER, glState.defaultFBO);
glState.currentFBO = NULL;
}
/*
============
RE_SetDefaultFramebuffer
Substitutes the framebuffer the renderer treats as the screen. In VR each eye
is an OpenXR swapchain image, so the whole render path - including the
post-processing resolve, which is what actually reaches the display - lands
there instead of in the window. Zero restores the window.
============
*/
void RE_SetDefaultFramebuffer(unsigned int framebuffer)
{
if (glState.defaultFBO == framebuffer) {
return;
}
glState.defaultFBO = framebuffer;
// Rendering into a framebuffer means something else is presenting it, so
// the window must not also be swapped.
GLimp_SetPresentsToWindow(framebuffer == 0 ? qtrue : qfalse);
// Whatever was bound describes the old target, so force the next FBO_Bind
// to issue a real bind rather than believe its cached state.
glState.currentFBO = NULL;
// Only the renderer's own binding is conditional here. The caller has
// already bound the target itself, because when framebuffers are disabled
// this entry point does not exist to be called.
if (qglBindFramebuffer) {
GL_BindFramebuffer(GL_FRAMEBUFFER, framebuffer);
}
}
/*
============
RE_SetVRView
============
*/
vrViewState_t vrView;
void RE_SetVRView(const float *origin, const vec3_t *axis,
float tanLeft, float tanRight, float tanUp, float tanDown,
float baseYaw, int eye)
{
if (!origin || !axis) {
vrView.active = qfalse;
return;
}
vrView.baseYaw = baseYaw;
vrView.eye = eye;
VectorCopy(origin, vrView.origin);
VectorCopy(axis[0], vrView.axis[0]);
VectorCopy(axis[1], vrView.axis[1]);
VectorCopy(axis[2], vrView.axis[2]);
vrView.tanLeft = tanLeft;
vrView.tanRight = tanRight;
vrView.tanUp = tanUp;
vrView.tanDown = tanDown;
vrView.active = qtrue;
}
+2
View File
@@ -57,6 +57,8 @@ void FBO_Bind(FBO_t *fbo);
void FBO_Init(void);
void FBO_Shutdown(void);
void RE_SetDefaultFramebuffer(unsigned int framebuffer);
void FBO_BlitFromTexture(struct image_s *src, vec4_t inSrcTexCorners, vec2_t inSrcTexScale, FBO_t *dst, ivec4_t inDstBox, struct shaderProgram_s *shaderProgram, vec4_t inColor, int blend);
void FBO_Blit(const FBO_t *src, ivec4_t srcBox, vec2_t srcTexScale, FBO_t *dst, ivec4_t dstBox, struct shaderProgram_s *shaderProgram, vec4_t color, int blend);
void FBO_FastBlit(const FBO_t *src, ivec4_t srcBox, FBO_t *dst, ivec4_t dstBox, int buffers, int filter);
+44 -3
View File
@@ -533,10 +533,10 @@ void R_LoadFontShader(fontheader_sgl_t* font)
{
for (i = 0; i < fontshader->numUnfoggedPasses; i++)
{
if (fontshader->stages[0] != NULL && fontshader->stages[0]->active)
if (fontshader->stages[i] != NULL && fontshader->stages[i]->active)
{
fontshader->stages[0]->rgbGen = CGEN_ENTITY;
fontshader->stages[0]->alphaGen = AGEN_ENTITY;
fontshader->stages[i]->rgbGen = CGEN_ENTITY;
fontshader->stages[i]->alphaGen = AGEN_ENTITY;
}
}
@@ -548,6 +548,43 @@ void R_LoadFontShader(fontheader_sgl_t* font)
}
}
/*
================
R_FontTraceBegin / R_FontTraceEnd
Temporary instrumentation, to settle whether the cost of the HUD is the strings
themselves. Both string paths flush the render command buffer and then drive the
back end directly, once per call - on a tiler with a framebuffer bound that can
end the render pass and force a resolve every time. Counting the calls alongside
the time spent in them says whether that is where the frame is going.
================
*/
static int s_fontMsec;
static int s_fontCalls;
static int s_fontLastReport;
static int R_FontTraceBegin( void ) {
return ri.Milliseconds();
}
static void R_FontTraceEnd( int start ) {
int now = ri.Milliseconds();
s_fontMsec += now - start;
s_fontCalls++;
if ( now - s_fontLastReport < 1000 ) {
return;
}
ri.Printf( PRINT_ALL, "VR font: %d calls, %dms a second\n", s_fontCalls, s_fontMsec );
s_fontLastReport = now;
s_fontCalls = 0;
s_fontMsec = 0;
}
void R_DrawString_sgl(fontheader_sgl_t* font, const char* text, float x, float y, int maxlen, const float *pvVirtualScreen) {
float charHeight;
float startx, starty;
@@ -575,6 +612,8 @@ void R_DrawString_sgl(fontheader_sgl_t* font, const char* text, float x, float y
return;
}
const int fontTraceStart = R_FontTraceBegin();
R_IssuePendingRenderCommands();
if (font->trhandle != r_sequencenumber) {
@@ -694,6 +733,8 @@ void R_DrawString_sgl(fontheader_sgl_t* font, const char* text, float x, float y
}
RB_EndSurface();
R_FontTraceEnd( fontTraceStart );
}
void R_DrawString(fontheader_t* font, const char* text, float x, float y, int maxlen, const float *pvVirtualScreen) {
+20
View File
@@ -2247,6 +2247,26 @@ image_t *R_CreateImage2( const char *name, byte *pic, int width, int height, GLe
dataFormat = GL_RGBA;
dataType = GL_UNSIGNED_SHORT_4_4_4_4;
break;
// Depth textures are the render target formats, needed once
// framebuffer objects are in use. ES 3.0 takes sized internal
// formats directly, so these pass through unchanged and only the
// data type has to be spelled out.
case GL_DEPTH_COMPONENT16:
dataFormat = GL_DEPTH_COMPONENT;
dataType = GL_UNSIGNED_SHORT;
break;
case GL_DEPTH_COMPONENT24:
dataFormat = GL_DEPTH_COMPONENT;
dataType = GL_UNSIGNED_INT;
break;
case GL_DEPTH_COMPONENT32F:
dataFormat = GL_DEPTH_COMPONENT;
dataType = GL_FLOAT;
break;
case GL_DEPTH24_STENCIL8:
dataFormat = GL_DEPTH_STENCIL;
dataType = GL_UNSIGNED_INT_24_8;
break;
default:
ri.Error( ERR_DROP, "Missing OpenGL ES support for image '%s' with internal format 0x%X\n", name, internalFormat );
}
+28
View File
@@ -207,6 +207,10 @@ cvar_t *r_lodCurveError;
cvar_t *r_fullscreen;
cvar_t *r_noborder;
cvar_t *r_vrTrace;
cvar_t *vr_fovZoom;
cvar_t *vr_hudScale;
cvar_t *vr_hudDepth;
cvar_t *r_customwidth;
cvar_t *r_customheight;
cvar_t *r_customPixelAspect;
@@ -483,6 +487,12 @@ qboolean R_GetModeInfo( int *width, int *height, float *windowAspect, int mode )
vidmode_t *vm;
float pixelAspect;
// A headset dictates the render size; whatever r_mode says is irrelevant.
if ( ri.GetVRRenderResolution && ri.GetVRRenderResolution( width, height ) ) {
*windowAspect = (float)*width / (float)*height;
return qtrue;
}
if ( mode < -1 ) {
return qfalse;
}
@@ -1341,7 +1351,16 @@ void R_Register( void )
r_ext_compiled_vertex_array = ri.Cvar_Get( "r_ext_compiled_vertex_array", "1", CVAR_ARCHIVE | CVAR_LATCH);
r_ext_texture_env_add = ri.Cvar_Get( "r_ext_texture_env_add", "1", CVAR_ARCHIVE | CVAR_LATCH);
#ifdef __ANDROID__
// The renderer's own render targets buy nothing in VR - each eye is already
// an OpenXR swapchain image, which is what the compositor displays - and
// they would cost a second full resolution pass and resolve per eye at
// 1680x1760. The entry points are still loaded, so the eye framebuffers
// work; this only turns off the renderer's internal use of them.
r_ext_framebuffer_object = ri.Cvar_Get( "r_ext_framebuffer_object", "0", CVAR_ARCHIVE | CVAR_LATCH);
#else
r_ext_framebuffer_object = ri.Cvar_Get( "r_ext_framebuffer_object", "1", CVAR_ARCHIVE | CVAR_LATCH);
#endif
r_ext_texture_float = ri.Cvar_Get( "r_ext_texture_float", "1", CVAR_ARCHIVE | CVAR_LATCH);
r_ext_framebuffer_multisample = ri.Cvar_Get( "r_ext_framebuffer_multisample", "0", CVAR_ARCHIVE | CVAR_LATCH);
r_arb_seamless_cube_map = ri.Cvar_Get( "r_arb_seamless_cube_map", "0", CVAR_ARCHIVE | CVAR_LATCH);
@@ -1368,6 +1387,13 @@ void R_Register( void )
r_mode = ri.Cvar_Get( "r_mode", "-2", CVAR_ARCHIVE | CVAR_LATCH );
r_fullscreen = ri.Cvar_Get( "r_fullscreen", "1", CVAR_ARCHIVE );
r_noborder = ri.Cvar_Get("r_noborder", "0", CVAR_ARCHIVE | CVAR_LATCH);
r_vrTrace = ri.Cvar_Get( "r_vrTrace", "0", 0 );
// Set by cgame from the fov the game asked for; see CG_CalcFov.
vr_fovZoom = ri.Cvar_Get( "vr_fovZoom", "1", 0 );
// See RB_SetGL2D. Scale is the share of the display the HUD is drawn across;
// depth is how far out it is made to converge.
vr_hudScale = ri.Cvar_Get( "vr_hudScale", "0.55", CVAR_ARCHIVE );
vr_hudDepth = ri.Cvar_Get( "vr_hudDepth", "2.0", CVAR_ARCHIVE );
r_customwidth = ri.Cvar_Get( "r_customwidth", "1600", CVAR_ARCHIVE | CVAR_LATCH );
r_customheight = ri.Cvar_Get( "r_customheight", "1024", CVAR_ARCHIVE | CVAR_LATCH );
r_customPixelAspect = ri.Cvar_Get( "r_customPixelAspect", "1", CVAR_ARCHIVE | CVAR_LATCH );
@@ -1989,6 +2015,8 @@ refexport_t *GetRefAPI ( int apiVersion, refimport_t *rimp ) {
re.BeginFrame = RE_BeginFrame;
re.EndFrame = RE_EndFrame;
re.SetDefaultFramebuffer = RE_SetDefaultFramebuffer;
re.SetVRView = RE_SetVRView;
re.MarkFragments = R_MarkFragments;
re.LerpTag = R_LerpTag;
+35
View File
@@ -1876,6 +1876,11 @@ typedef struct {
mat4_t boneMatrix[IQM_MAX_JOINTS];
uint32_t vertexAttribsEnabled; // global if no VAOs, tess only otherwise
FBO_t *currentFBO;
// What "no FBO" resolves to. Zero is the window, which is the normal case;
// in VR each eye's swapchain image is substituted here so that the whole
// renderer draws into the headset without knowing about it.
GLuint defaultFBO;
vao_t *currentVao;
mat4_t modelview;
mat4_t projection;
@@ -2215,6 +2220,32 @@ typedef struct {
int farclip;
} trGlobals_t;
/*
The eye currently being rendered, when driven by a headset.
Kept outside trGlobals_t for the same reason as glState: it describes the
display rather than the scene, and must survive a renderer re-init.
*/
typedef struct {
qboolean active;
vec3_t origin; // head offset from the game's camera, engine units
vec3_t axis[3]; // head orientation as forward/left/up
float tanLeft; // asymmetric frustum edges at unit distance
float tanRight;
float tanUp;
float tanDown;
float baseYaw; // head heading the game's camera already carries
int eye; // which eye, for the HUD's stereo offset
} vrViewState_t;
extern vrViewState_t vrView;
void R_VRAdjust2DRect( int *x, int *y, int *w, int *h );
void RE_SetVRView( const float *origin, const vec3_t *axis,
float tanLeft, float tanRight, float tanUp, float tanDown,
float baseYaw, int eye );
extern backEndState_t backEnd;
extern trGlobals_t tr;
extern glstate_t glState; // outside of TR since it shouldn't be cleared during ref re-init
@@ -2242,6 +2273,10 @@ extern cvar_t *r_stereoSeparation; // separation of cameras for stereo renderi
extern cvar_t *r_measureOverdraw; // enables stencil buffer overdraw measurement
extern cvar_t *r_vrTrace; // log the composed VR camera once a second
extern cvar_t *vr_fovZoom; // scope magnification, published by cgame
extern cvar_t *vr_hudScale; // how much of the display the HUD covers
extern cvar_t *vr_hudDepth; // metres out the HUD is made to converge
extern cvar_t *r_lodbias; // push/pull LOD transitions
extern cvar_t *r_lodscale;
+42 -7
View File
@@ -851,11 +851,46 @@ void R_SetupProjection(viewParms_t *dest, float zProj, float zFar, qboolean comp
stereoSep = 0;
}
ymax = zProj * tan(dest->fovY * M_PI / 360.0f);
ymin = -ymax;
if (vrView.active)
{
// A headset's per-eye frustum is not centred on the view axis - each
// eye sees further towards its own side than towards the nose. Using a
// symmetric approximation misaligns the two images by a few degrees,
// which reads as an image the eyes cannot fuse. The matrix below
// already carries the off-centre terms, so the runtime's own edges go
// straight in.
// Scopes and binoculars. The game narrows its fov to zoom, but in a
// headset the frustum comes from the runtime, so that request would
// otherwise be dropped on the floor. Narrowing the eye frustum by the
// same factor while the composition layer keeps claiming the headset's
// full field of view leaves the compositor to magnify the result, which
// is both free and sharper than zooming after the fact: the narrow view
// was rendered into the whole eye texture.
float zoom = vr_fovZoom ? vr_fovZoom->value : 1.0f;
xmax = zProj * tan(dest->fovX * M_PI / 360.0f);
xmin = -xmax;
if ( zoom < 1.0f ) {
zoom = 1.0f;
} else if ( zoom > 16.0f ) {
zoom = 16.0f;
}
xmin = zProj * vrView.tanLeft / zoom;
xmax = zProj * vrView.tanRight / zoom;
ymin = zProj * vrView.tanDown / zoom;
ymax = zProj * vrView.tanUp / zoom;
// Stereo separation is the flat renderer's way of faking two eyes, and
// would be applied on top of a real one.
stereoSep = 0;
}
else
{
ymax = zProj * tan(dest->fovY * M_PI / 360.0f);
ymin = -ymax;
xmax = zProj * tan(dest->fovX * M_PI / 360.0f);
xmin = -xmax;
}
width = xmax - xmin;
height = ymax - ymin;
@@ -3119,13 +3154,13 @@ void R_AddSpriteSurfaces()
tr.currentEntityNum = ENTITYNUM_WORLD;
tr.shiftedEntityNum = tr.currentEntityNum << QSORT_REFENTITYNUM_SHIFT;
if (sprite->hModel && sprite->hModel < tr.numModels) {
if (sprite->hModel > 0 && sprite->hModel < tr.numModels) {
tr.currentModel = tr.models[sprite->hModel];
} else {
tr.currentModel = tr.models[0];
continue;
}
if (tr.currentModel->type != MOD_SPRITE) {
if (tr.currentModel->type != MOD_SPRITE || !tr.currentModel->d.sprite) {
continue;
}
+87
View File
@@ -518,9 +518,90 @@ Rendering a scene may require multiple views to be rendered
to handle mirrors,
@@@@@@@@@@@@@@@@@@@@@
*/
/*
====================
R_ApplyVRView
Puts the headset on top of whatever camera the game asked for.
Done here rather than in the game code because every camera reaches the
renderer through this one function - the player's view, cutscene cameras,
ladder climbs, death animations - so head tracking follows all of them without
each having to know about VR.
The game's camera decides where the body is and which way it faces. Everything
else comes from the headset: pitch and roll are taken from the head alone,
since blending them with the game's would fight the viewer's own neck.
====================
*/
static void R_ApplyVRView( refdef_t *fd ) {
vec3_t baseAngles, bodyAngles, bodyAxis[3];
int i;
if ( !vrView.active ) {
return;
}
// The game's camera decides where the body stands and which way it faces;
// the headset decides everything about where the eyes are relative to that.
// Only the heading is taken from the game - its pitch and roll would fight
// the viewer's own neck.
//
// Minus whatever heading the client already fed into the view angles. The
// game is told where the player is looking so that objectives and anything
// else consulting the view angles agree with what is in front of them, but
// that same heading then comes back through the player state and into this
// camera - and composing the head onto it again turns it twice. Yaw ends up
// doubled while pitch, which the game's camera does not carry here, does
// not, and half of the doubled yaw arrives a frame late through prediction.
// The result reads as a world that swims when the head turns.
//
// Taking it back off leaves the game's own contributions - recoil, lean,
// cutscene cameras, ladders - composed with the current headset pose, which
// is what this function is for.
vectoangles( fd->viewaxis[0], baseAngles );
VectorSet( bodyAngles, 0, baseAngles[YAW] - vrView.baseYaw, 0 );
AnglesToAxis( bodyAngles, bodyAxis );
// Rotate the head's axes into the body's frame. Composed as a rotation
// rather than by adding Euler angles: adding them only happens to work
// while pitch and roll are small, and turns every head movement into a
// twist once they are not.
for ( i = 0; i < 3; i++ ) {
vec3_t rotated;
VectorScale( bodyAxis[0], vrView.axis[i][0], rotated );
VectorMA( rotated, vrView.axis[i][1], bodyAxis[1], rotated );
VectorMA( rotated, vrView.axis[i][2], bodyAxis[2], rotated );
VectorCopy( rotated, fd->viewaxis[i] );
}
if ( r_vrTrace && r_vrTrace->integer ) {
static int lastTrace;
int now = ri.Milliseconds();
if ( now - lastTrace > 1000 ) {
lastTrace = now;
ri.Printf( PRINT_ALL,
"VR view: org %.0f %.0f %.0f body yaw %.1f | head off %.1f %.1f %.1f fwd %.2f %.2f %.2f\n",
fd->vieworg[0], fd->vieworg[1], fd->vieworg[2], baseAngles[YAW],
vrView.origin[0], vrView.origin[1], vrView.origin[2],
vrView.axis[0][0], vrView.axis[0][1], vrView.axis[0][2] );
}
}
// The head's offset within the play space, turned to face the same way the
// body does before it is added. Leaning left stays leaning left whichever
// way the player has turned.
VectorMA( fd->vieworg, vrView.origin[0], bodyAxis[0], fd->vieworg );
VectorMA( fd->vieworg, vrView.origin[1], bodyAxis[1], fd->vieworg );
VectorMA( fd->vieworg, vrView.origin[2], bodyAxis[2], fd->vieworg );
}
void RE_RenderScene( const refdef_t *fd ) {
viewParms_t parms;
int startTime;
refdef_t vrRefdef;
if ( !tr.registered ) {
return;
@@ -531,6 +612,12 @@ void RE_RenderScene( const refdef_t *fd ) {
return;
}
if ( vrView.active ) {
vrRefdef = *fd;
R_ApplyVRView( &vrRefdef );
fd = &vrRefdef;
}
startTime = ri.Milliseconds();
if (!tr.world && !( fd->rdflags & RDF_NOWORLDMODEL ) ) {
+68 -6
View File
@@ -372,12 +372,25 @@ static void AutospriteDeform( void ) {
tess.numIndexes = 0;
tess.firstIndex = 0;
if ( backEnd.currentEntity != &tr.worldEntity ) {
GlobalVectorToLocal( backEnd.viewParms.ori.axis[1], leftDir );
GlobalVectorToLocal( backEnd.viewParms.ori.axis[2], upDir );
} else {
VectorCopy( backEnd.viewParms.ori.axis[1], leftDir );
VectorCopy( backEnd.viewParms.ori.axis[2], upDir );
{
vec3_t viewLeft, viewUp;
VectorCopy( backEnd.viewParms.ori.axis[1], viewLeft );
VectorCopy( backEnd.viewParms.ori.axis[2], viewUp );
if ( vrView.active ) {
// World up, so the sprites stay upright rather than inheriting the
// head's roll.
VectorSet( viewUp, 0.0f, 0.0f, 1.0f );
}
if ( backEnd.currentEntity != &tr.worldEntity ) {
GlobalVectorToLocal( viewLeft, leftDir );
GlobalVectorToLocal( viewUp, upDir );
} else {
VectorCopy( viewLeft, leftDir );
VectorCopy( viewUp, upDir );
}
}
for ( i = 0 ; i < oldVerts ; i+=4 ) {
@@ -392,9 +405,37 @@ static void AutospriteDeform( void ) {
VectorSubtract( xyz, mid, delta );
radius = VectorLength( delta ) * 0.707f; // / sqrt(2)
// Quake's autosprites align to the view plane: every sprite takes the
// camera's own axes, so they all sit parallel to the screen. On a
// monitor that is indistinguishable from facing the viewer, but in VR
// turning the head swings the whole set round to stay parallel to the
// view plane, and they visibly spin in place. Point each one at where
// the viewer actually is instead, so head rotation leaves them alone
// and only moving changes what they present.
if ( vrView.active ) {
vec3_t toViewer, spriteLeft, spriteUp;
VectorSubtract( backEnd.ori.viewOrigin, mid, toViewer );
CrossProduct( upDir, toViewer, spriteLeft );
// Degenerate directly above or below the sprite, where there is no
// heading to align to; the shared axes are as good as anything.
if ( VectorNormalize( spriteLeft ) > 0.001f ) {
CrossProduct( toViewer, spriteLeft, spriteUp );
if ( VectorNormalize( spriteUp ) > 0.001f ) {
VectorScale( spriteLeft, radius, left );
VectorScale( spriteUp, radius, up );
goto haveAxes;
}
}
}
VectorScale( leftDir, radius, left );
VectorScale( upDir, radius, up );
haveAxes:
if ( backEnd.viewParms.isMirror ) {
VectorSubtract( vec3_origin, left, left );
}
@@ -514,6 +555,27 @@ static void Autosprite2Deform( void ) {
VectorSubtract( mid[1], mid[0], major );
// cross this with the view direction to get minor axis
if ( vrView.active ) {
// Towards the viewer, not along the view axis. On a monitor the two
// amount to the same thing - the eye is effectively infinitely far
// away down the middle of the screen - but in a headset they are
// not, and taking the view axis swings every one of these sprites
// as the head turns while the geometry it stands beside holds
// still. That is the second, worse tree leaning out of every real
// one: the imposter and the model disagreeing about where the
// viewer is.
//
// Per sprite rather than per batch for the same reason: each one
// has to face its own line to the eye, and a single direction for
// the whole surface is only right for a sprite dead ahead.
vec3_t centre;
VectorAdd( mid[0], mid[1], centre );
VectorScale( centre, 0.5f, centre );
VectorSubtract( centre, backEnd.ori.viewOrigin, forward );
VectorNormalize( forward );
}
CrossProduct( major, forward, minor );
VectorNormalize( minor );
+64 -15
View File
@@ -61,13 +61,13 @@ static int CullSprite(const vec3_t* points) {
for (i = 0; i < backEnd.viewParms.fog.extrafrustums + 4; i++) {
for (j = 0; j < 4; j++) {
if (DotProduct(points[j], backEnd.viewParms.frustum[i].normal) - backEnd.viewParms.frustum[i].dist > 0.0f) {
if (DotProduct(points[j], backEnd.viewParms.frustum[i].normal) >= backEnd.viewParms.frustum[i].dist) {
break;
}
}
if (j == 4) {
return CULL_CLIP;
return CULL_OUT;
}
}
@@ -88,15 +88,15 @@ void RB_DrawSprite( const refSprite_t *spr ) {
float scale;
int i;
if (!spr->hModel)
if (spr->hModel <= 0 || spr->hModel >= tr.numModels)
{
ri.Printf(PRINT_WARNING, "No model found for Sprite\n");
return;
}
model = tr.models;
if (spr->hModel > 0 && spr->hModel < tr.numModels) {
model = tr.models[spr->hModel];
model = tr.models[spr->hModel];
if (model->type != MOD_SPRITE || !model->d.sprite) {
return;
}
scale = spr->scale * model->d.sprite->scale;
@@ -109,10 +109,16 @@ void RB_DrawSprite( const refSprite_t *spr ) {
float cr, sr;
int i;
invmag = 1.0 / sqrt(spr->axis[1][2] * spr->axis[1][2] + spr->axis[1][1] * spr->axis[1][1]);
cr = invmag * spr->axis[1][1];
sr = invmag * spr->axis[1][2];
invmag = spr->axis[1][2] * spr->axis[1][2] + spr->axis[1][1] * spr->axis[1][1];
if (invmag > 0.0f) {
invmag = 1.0f / sqrt(invmag);
cr = invmag * spr->axis[1][1];
sr = invmag * spr->axis[1][2];
} else {
// No projected roll axis: keep the quad finite and unrotated.
cr = 1.0f;
sr = 0.0f;
}
for (i = 0; i < 3; i++)
{
@@ -122,6 +128,28 @@ void RB_DrawSprite( const refSprite_t *spr ) {
}
break;
case SPRITE_PARALLEL:
// In VR, face where the viewer is rather than lying parallel to the
// view plane. Parallel to the view plane means every sprite in the
// world swings round together the moment the head turns, which is
// invisible on a monitor and glaring in a headset.
if (vrView.active) {
vec3_t toViewer;
VectorSubtract(backEnd.viewParms.ori.origin, spr->origin, toViewer);
if (VectorNormalize(toViewer) > 0.001f) {
vec3_t worldUp = { 0.0f, 0.0f, 1.0f };
CrossProduct(worldUp, toViewer, right);
if (VectorNormalize(right) > 0.001f) {
CrossProduct(toViewer, right, up);
VectorNormalize(up);
break;
}
}
}
VectorCopy(backEnd.viewParms.ori.axis[2], up);
if (!backEnd.viewParms.isMirror) {
VectorNegate(backEnd.viewParms.ori.axis[1], right);
@@ -137,15 +165,36 @@ void RB_DrawSprite( const refSprite_t *spr ) {
VectorCopy(backEnd.viewParms.ori.axis[0], norm);
VectorCopy(backEnd.viewParms.ori.axis[1], right);
VectorCopy(backEnd.viewParms.ori.axis[2], up);
if (backEnd.viewParms.ori.axis[0][2] > 0.999) {
return;
} else if (backEnd.viewParms.ori.axis[0][2] < -0.999) {
// VR facing comes from the viewer's position, so looking vertically
// does not make an otherwise visible upright sprite degenerate.
if (!vrView.active && fabs(backEnd.viewParms.ori.axis[0][2]) > 0.999f) {
return;
}
VectorSet(up, 0.0f, 0.0f, 1.0f);
VectorSet(right, backEnd.viewParms.ori.axis[0][1], -backEnd.viewParms.ori.axis[0][0], 0.0f);
VectorNormalize(right);
// Upright sprites already ignore the view's roll and pitch, but they
// still turn with the view's heading rather than towards the viewer -
// so in VR they spin in place as the head turns. Aim each one at where
// the viewer actually stands instead.
if (vrView.active) {
vec3_t toViewer;
VectorSubtract(backEnd.viewParms.ori.origin, spr->origin, toViewer);
toViewer[2] = 0.0f;
if (VectorNormalize(toViewer) > 0.001f) {
VectorSet(right, -toViewer[1], toViewer[0], 0.0f);
} else {
// Directly above/below: use a stable axis, even when the view
// direction is vertical too.
VectorSet(right, 1.0f, 0.0f, 0.0f);
}
} else {
VectorSet(right, backEnd.viewParms.ori.axis[0][1], -backEnd.viewParms.ori.axis[0][0], 0.0f);
VectorNormalize(right);
}
VectorSet(norm, -right[1], right[0], 0.0f);
break;
}
+336 -20
View File
@@ -35,6 +35,11 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#include "../sys/sys_local.h"
#include "sdl_icon.h"
#ifdef USE_GL4ES
#include <dlfcn.h>
#include <EGL/egl.h>
#endif
typedef enum
{
RSERR_OK,
@@ -252,6 +257,137 @@ static void APIENTRY GLimp_GLES_PolygonMode( GLenum face, GLenum mode ) {
// unsupported
}
#ifdef USE_GL4ES
/*
===============
GLimp_GL4ES_GetProcAddress
The fixed function renderer's entry points have to come from gl4es and from
nowhere else.
SDL cannot be asked for them. On EGL 1.5 - which the Quest is -
SDL_EGL_GetProcAddress tries eglGetProcAddress before it tries the library it
loaded, and the driver answers for every name OpenGL 1.x and ES have in common:
glEnable, glBindTexture, glDrawArrays, glTexImage2D and eighty more. Only the
names that exist nowhere but desktop GL - glBegin, glMatrixMode - would reach
gl4es. That split is worse than either half on its own, because gl4es batches
geometry and tracks the matrix stack against calls the driver would then never
be told about.
So resolve against the gl4es handle directly. It is already in the process, in
DT_NEEDED, so this dlopen only takes a reference to it.
===============
*/
static void *GLimp_GL4ES_GetProcAddress( const char *name ) {
static void *gl4es = NULL;
if ( !gl4es ) {
gl4es = dlopen( "libgl4es.so", RTLD_NOW | RTLD_LOCAL );
if ( !gl4es ) {
Com_Error( ERR_FATAL, "Could not open libgl4es.so: %s", dlerror() );
}
// NO_INIT_CONSTRUCTOR is set, so gl4es does not attach itself to the
// context behind our back; it is told to, once the context is current.
void ( *initialize_gl4es )( void ) = dlsym( gl4es, "initialize_gl4es" );
if ( !initialize_gl4es ) {
Com_Error( ERR_FATAL, "libgl4es.so has no initialize_gl4es" );
}
initialize_gl4es();
}
return dlsym( gl4es, name );
}
/*
===============
GLimp_ExtensionSupported
And the extension *string* has to come from gl4es too. SDL_GL_ExtensionSupported
resolves glGetString through SDL, so it reads the driver's list - which is the
ES one, and names none of the desktop extensions the renderer asks after. Left
to SDL, GL_ARB_multitexture reads as absent and the renderer quietly drops to a
single texture unit.
qglGetString is gl4es's by this point; GLimp_GetProcAddresses has already run.
===============
*/
static qboolean GLimp_ExtensionSupported( const char *extension ) {
const char *extensions = (const char *)qglGetString( GL_EXTENSIONS );
const char *p;
size_t len = strlen( extension );
if ( !extensions ) {
return qfalse;
}
// Whole tokens only, or GL_EXT_texture_compression_s3tc would answer for a
// query about GL_EXT_texture_compression.
for ( p = extensions; ( p = strstr( p, extension ) ) != NULL; p += len ) {
if ( ( p == extensions || p[-1] == ' ' ) && ( p[len] == ' ' || p[len] == '\0' ) ) {
return qtrue;
}
}
return qfalse;
}
#else
#define GLimp_ExtensionSupported( name ) SDL_GL_ExtensionSupported( name )
#endif
/*
===============
GLimp_GetProcAddress
The renderer's one way of asking for a GL entry point, so that everything it
calls comes from the same implementation. Everything above resolves through
this, and so does anything a renderer needs to load for itself - renderergl1
has no extension probe of its own and asks for the framebuffer entry points
here.
===============
*/
void *GLimp_GetProcAddress( const char *name ) {
#ifdef USE_GL4ES
return GLimp_GL4ES_GetProcAddress( name );
#else
return SDL_GL_GetProcAddress( name );
#endif
}
/*
===============
GLimp_MakeCurrent
Bind the window's GL context to the calling thread again.
The VR layer hands the OpenXR session the EGL context the engine is already
using, so it needs one to be current at the moment it asks. Whether it is
depends on what SDL and the platform have done with the window since - a hidden
window on Android has no surface to be current against yet, and being current
is per thread in any case. Rather than assume, the session asks for it.
Returns false if there is no context to bind, which is a different thing from
failing to bind one.
===============
*/
qboolean GLimp_MakeCurrent( void ) {
if ( !SDL_window || !SDL_glContext ) {
ri.Printf( PRINT_ALL, "GLimp_MakeCurrent: no window (%p) or context (%p)\n",
(void *)SDL_window, (void *)SDL_glContext );
return qfalse;
}
if ( SDL_GL_MakeCurrent( SDL_window, SDL_glContext ) < 0 ) {
ri.Printf( PRINT_ALL, "SDL_GL_MakeCurrent failed: %s\n", SDL_GetError() );
return qfalse;
}
return qtrue;
}
/*
===============
GLimp_GetProcAddresses
@@ -259,12 +395,96 @@ GLimp_GetProcAddresses
Get addresses for OpenGL functions.
===============
*/
#ifdef USE_GL4ES
/*
===============
GLimp_BindContextToPbuffer
Give the GL context something to be current against, before anything asks it a
question.
SDL binds the context to the window's EGL surface, and in a headset no such
surface ever arrives - so SDL_EGL_MakeCurrent unbinds everything and returns
success, leaving SDL certain a context is current while EGL reports none.
That was already known; what was missed is how early it matters. gl4es
initialises on the first proc address request, which is the next thing that
happens after the context is created. With nothing current, its hardware
detection and its shader pipeline are set up against no GL at all. The result
is not a failure anyone sees: clears go straight through to the driver and work
perfectly, while every piece of geometry gl4es is asked to draw disappears in
silence, no error raised.
A 16x16 pbuffer is enough to be current against, which is what RTCWQuest uses
(TBXR_Common.c, egl->TinySurface). The VR layer does the same thing later for
the OpenXR session; this is the same fix moved to where it has to happen first.
===============
*/
static void GLimp_BindContextToPbuffer( void )
{
static const EGLint pbufferAttribs[] = { EGL_WIDTH, 16, EGL_HEIGHT, 16, EGL_NONE };
EGLDisplay display;
EGLContext context;
EGLConfig config = NULL;
EGLint configId = 0, numConfigs = 0;
EGLint configAttribs[3] = { EGL_CONFIG_ID, 0, EGL_NONE };
EGLSurface pbuffer;
if ( eglGetCurrentContext() != EGL_NO_CONTEXT ) {
return;
}
display = eglGetDisplay( EGL_DEFAULT_DISPLAY );
context = (EGLContext)SDL_glContext;
if ( display == EGL_NO_DISPLAY || !context ) {
ri.Printf( PRINT_ALL, "gl4es: no EGL context to bind; geometry will not draw\n" );
return;
}
eglQueryContext( display, context, EGL_CONFIG_ID, &configId );
configAttribs[1] = configId;
if ( !eglChooseConfig( display, configAttribs, &config, 1, &numConfigs ) || numConfigs < 1 ) {
ri.Printf( PRINT_ALL, "gl4es: could not recover EGLConfig %d\n", configId );
return;
}
pbuffer = eglCreatePbufferSurface( display, config, pbufferAttribs );
if ( pbuffer == EGL_NO_SURFACE ) {
ri.Printf( PRINT_ALL, "gl4es: could not create the pbuffer (0x%x)\n", eglGetError() );
return;
}
if ( !eglMakeCurrent( display, pbuffer, pbuffer, context ) ) {
ri.Printf( PRINT_ALL, "gl4es: eglMakeCurrent on the pbuffer failed (0x%x)\n", eglGetError() );
return;
}
ri.Printf( PRINT_ALL, "gl4es: context bound to a 16x16 pbuffer before init\n" );
}
#endif
#ifdef USE_GL4ES
static const qboolean usingGL4ES = qtrue;
#else
static const qboolean usingGL4ES = qfalse;
#endif
static qboolean GLimp_GetProcAddresses( qboolean fixedFunction ) {
qboolean success = qtrue;
const char *version;
#ifdef __SDL_NOGETPROCADDR__
#define GLE( ret, name, ... ) qgl##name = gl#name;
#elif defined( USE_GL4ES )
#define GLE( ret, name, ... ) qgl##name = (name##proc *) GLimp_GL4ES_GetProcAddress("gl" #name); \
if ( qgl##name == NULL ) { \
ri.Printf( PRINT_ALL, "ERROR: Missing OpenGL function %s\n", "gl" #name ); \
success = qfalse; \
}
#else
#define GLE( ret, name, ... ) qgl##name = (name##proc *) SDL_GL_GetProcAddress("gl" #name); \
if ( qgl##name == NULL ) { \
@@ -320,6 +540,22 @@ static qboolean GLimp_GetProcAddresses( qboolean fixedFunction ) {
if ( QGL_VERSION_ATLEAST( 1, 3 ) ) {
QGL_1_3_PROCS;
}
#ifdef USE_GL4ES
// gl4es reports a desktop version, so this takes the desktop branch
// above and picks up the real glDrawBuffer and glPolygonMode. Neither
// means anything underneath, where the context is OpenGL ES - which is
// why the ES branch below stubs exactly these two out.
//
// glDrawBuffer is the one that matters. The back end names GL_BACK once
// a frame in RB_DrawBuffer, and GL_BACK is not a buffer a framebuffer
// object has; asking for it while an eye or the panel is bound leaves
// the draw buffer in a state where everything afterwards is discarded.
// Correct geometry, correct matrices, no error on the draw itself, and
// nothing written anywhere - which is precisely what was happening.
qglDrawBuffer = GLimp_GLES_DrawBuffer;
qglPolygonMode = GLimp_GLES_PolygonMode;
#endif
} else {
if ( QGL_VERSION_ATLEAST( 2, 0 ) ) {
QGL_1_1_PROCS;
@@ -549,13 +785,14 @@ static int GLimp_SetMode(int mode, qboolean fullscreen, qboolean noborder, qbool
( r_preferOpenGLES->integer == -1 && profileMask == SDL_GL_CONTEXT_PROFILE_ES ) );
if ( preferOpenGLES ) {
#ifdef __EMSCRIPTEN__
// WebGL 2.0 isn't fully backward compatible so you have to ask for it specifically
// ES 3 has to be asked for by name. WebGL 2.0 isn't fully backward
// compatible, and EGL hands back a real ES 2.0 context when that is
// what was requested - so asking for 2.0 first would settle for it
// even where 3 is available.
contexts[numContexts].profileMask = SDL_GL_CONTEXT_PROFILE_ES;
contexts[numContexts].majorVersion = 3;
contexts[numContexts].minorVersion = 0;
numContexts++;
#endif
contexts[numContexts].profileMask = SDL_GL_CONTEXT_PROFILE_ES;
contexts[numContexts].majorVersion = 2;
@@ -574,12 +811,10 @@ static int GLimp_SetMode(int mode, qboolean fullscreen, qboolean noborder, qbool
numContexts++;
if ( !preferOpenGLES ) {
#ifdef __EMSCRIPTEN__
contexts[numContexts].profileMask = SDL_GL_CONTEXT_PROFILE_ES;
contexts[numContexts].majorVersion = 3;
contexts[numContexts].minorVersion = 0;
numContexts++;
#endif
contexts[numContexts].profileMask = SDL_GL_CONTEXT_PROFILE_ES;
contexts[numContexts].majorVersion = 2;
@@ -587,10 +822,37 @@ static int GLimp_SetMode(int mode, qboolean fullscreen, qboolean noborder, qbool
numContexts++;
}
} else {
#ifdef USE_GL4ES
// The fixed function renderer asks for a fixed function context, and on
// this platform there is no such thing - the request fails at
// SDL_CreateWindow with "Couldn't get a visual" and takes the renderer
// down with it.
//
// gl4es is what makes the request answerable, and it wants the opposite
// of what the renderer thinks it is getting: it presents desktop GL 1.x
// upwards while running on OpenGL ES underneath. So ask for ES here.
// The renderer is never told - it reads its version through gl4es, which
// reports desktop GL, and GLimp_GetProcAddresses takes the desktop
// fixed function branch on the strength of it.
//
// ES 3 first and by name, for the reason given above: EGL hands back a
// real ES 2.0 context when 2.0 is what was asked for, so asking for it
// first would settle for it even where 3 is available.
contexts[numContexts].profileMask = SDL_GL_CONTEXT_PROFILE_ES;
contexts[numContexts].majorVersion = 3;
contexts[numContexts].minorVersion = 0;
numContexts++;
contexts[numContexts].profileMask = SDL_GL_CONTEXT_PROFILE_ES;
contexts[numContexts].majorVersion = 2;
contexts[numContexts].minorVersion = 0;
numContexts++;
#else
contexts[numContexts].profileMask = 0;
contexts[numContexts].majorVersion = 1;
contexts[numContexts].minorVersion = 1;
numContexts++;
#endif
}
for (i = 0; i < 16; i++)
@@ -732,6 +994,32 @@ static int GLimp_SetMode(int mode, qboolean fullscreen, qboolean noborder, qbool
continue;
}
#ifdef USE_GL4ES
// The usual proof that a context is live is GL_VERSION coming back
// non-NULL from GLimp_GetProcAddresses below. That test does not
// work here: gl4es answers glGetString for VERSION, VENDOR and
// RENDERER out of its own constants, without asking the driver
// anything, so a context that was created but never bound sails
// through it and is only noticed much later - when the VR layer
// asks EGL for the current context and finds none.
if ( !SDL_GL_GetCurrentContext() )
{
ri.Printf( PRINT_ALL, "%s context was created but is not current: %s\n",
contextName, SDL_GetError() );
SDL_GL_DeleteContext( SDL_glContext );
SDL_glContext = NULL;
SDL_DestroyWindow( SDL_window );
SDL_window = NULL;
continue;
}
#endif
#ifdef USE_GL4ES
// Before GLimp_GetProcAddresses, because that is what initialises
// gl4es and gl4es needs a context to look at.
GLimp_BindContextToPbuffer();
#endif
if ( !GLimp_GetProcAddresses( fixedFunction ) )
{
ri.Printf( PRINT_ALL, "GLimp_GetProcAddresses() for %s context failed\n", contextName );
@@ -907,8 +1195,8 @@ static void GLimp_InitExtensions( qboolean fixedFunction )
glConfig.textureCompression = TC_NONE;
// GL_EXT_texture_compression_s3tc
if ( ( QGLES_VERSION_ATLEAST( 2, 0 ) || SDL_GL_ExtensionSupported( "GL_ARB_texture_compression" ) ) &&
SDL_GL_ExtensionSupported( "GL_EXT_texture_compression_s3tc" ) )
if ( ( QGLES_VERSION_ATLEAST( 2, 0 ) || GLimp_ExtensionSupported( "GL_ARB_texture_compression" ) ) &&
GLimp_ExtensionSupported( "GL_EXT_texture_compression_s3tc" ) )
{
if ( r_ext_compressed_textures->value )
{
@@ -928,7 +1216,7 @@ static void GLimp_InitExtensions( qboolean fixedFunction )
// GL_S3_s3tc ... legacy extension before GL_EXT_texture_compression_s3tc.
if (glConfig.textureCompression == TC_NONE)
{
if ( SDL_GL_ExtensionSupported( "GL_S3_s3tc" ) )
if ( GLimp_ExtensionSupported( "GL_S3_s3tc" ) )
{
if ( r_ext_compressed_textures->value )
{
@@ -951,7 +1239,7 @@ static void GLimp_InitExtensions( qboolean fixedFunction )
{
// GL_EXT_texture_env_add
glConfig.textureEnvAddAvailable = qfalse;
if ( SDL_GL_ExtensionSupported( "GL_EXT_texture_env_add" ) )
if ( GLimp_ExtensionSupported( "GL_EXT_texture_env_add" ) )
{
if ( r_ext_texture_env_add->integer )
{
@@ -973,13 +1261,13 @@ static void GLimp_InitExtensions( qboolean fixedFunction )
qglMultiTexCoord2fARB = NULL;
qglActiveTextureARB = NULL;
qglClientActiveTextureARB = NULL;
if ( SDL_GL_ExtensionSupported( "GL_ARB_multitexture" ) )
if ( GLimp_ExtensionSupported( "GL_ARB_multitexture" ) )
{
if ( r_ext_multitexture->value )
{
qglMultiTexCoord2fARB = SDL_GL_GetProcAddress( "glMultiTexCoord2fARB" );
qglActiveTextureARB = SDL_GL_GetProcAddress( "glActiveTextureARB" );
qglClientActiveTextureARB = SDL_GL_GetProcAddress( "glClientActiveTextureARB" );
qglMultiTexCoord2fARB = GLimp_GetProcAddress( "glMultiTexCoord2fARB" );
qglActiveTextureARB = GLimp_GetProcAddress( "glActiveTextureARB" );
qglClientActiveTextureARB = GLimp_GetProcAddress( "glClientActiveTextureARB" );
if ( qglActiveTextureARB )
{
@@ -1010,13 +1298,28 @@ static void GLimp_InitExtensions( qboolean fixedFunction )
}
// GL_EXT_compiled_vertex_array
if ( SDL_GL_ExtensionSupported( "GL_EXT_compiled_vertex_array" ) )
//
// Declined on gl4es. This was first adopted on evidence that turned out
// to be worthless - it was measured against a gl4es that could not draw
// a triangle at all - so it is kept on its own merits, not on that.
//
// The merits are thin but real: the extension is a 1997 hint about
// re-transforming shared vertices between draws, gl4es's glLockArrays
// only records first and count and sets a flag, and it re-uploads the
// arrays per batch regardless. There is nothing here to win.
//
// This no longer decides which route R_DrawElements takes. It used to,
// by accident: a NULL qglLockArraysEXT sent every surface down the
// glBegin/glArrayElement path, which is not a path the reference has
// ever run. That renderer now asks for glDrawElements under gl4es
// outright, the way RTCWQuest does under HAVE_GLES.
if ( !usingGL4ES && GLimp_ExtensionSupported( "GL_EXT_compiled_vertex_array" ) )
{
if ( r_ext_compiled_vertex_array->value )
{
ri.Printf( PRINT_ALL, "...using GL_EXT_compiled_vertex_array\n" );
qglLockArraysEXT = ( void ( APIENTRY * )( GLint, GLint ) ) SDL_GL_GetProcAddress( "glLockArraysEXT" );
qglUnlockArraysEXT = ( void ( APIENTRY * )( void ) ) SDL_GL_GetProcAddress( "glUnlockArraysEXT" );
qglLockArraysEXT = ( void ( APIENTRY * )( GLint, GLint ) ) GLimp_GetProcAddress( "glLockArraysEXT" );
qglUnlockArraysEXT = ( void ( APIENTRY * )( void ) ) GLimp_GetProcAddress( "glUnlockArraysEXT" );
if (!qglLockArraysEXT || !qglUnlockArraysEXT)
{
ri.Error (ERR_FATAL, "bad getprocaddress");
@@ -1034,7 +1337,7 @@ static void GLimp_InitExtensions( qboolean fixedFunction )
}
textureFilterAnisotropic = qfalse;
if ( SDL_GL_ExtensionSupported( "GL_EXT_texture_filter_anisotropic" ) )
if ( GLimp_ExtensionSupported( "GL_EXT_texture_filter_anisotropic" ) )
{
if ( r_ext_texture_filter_anisotropic->integer ) {
qglGetIntegerv( GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, (GLint *)&maxAnisotropy );
@@ -1059,7 +1362,7 @@ static void GLimp_InitExtensions( qboolean fixedFunction )
}
haveClampToEdge = qfalse;
if ( QGL_VERSION_ATLEAST( 1, 2 ) || QGLES_VERSION_ATLEAST( 1, 0 ) || SDL_GL_ExtensionSupported( "GL_SGIS_texture_edge_clamp" ) )
if ( QGL_VERSION_ATLEAST( 1, 2 ) || QGLES_VERSION_ATLEAST( 1, 0 ) || GLimp_ExtensionSupported( "GL_SGIS_texture_edge_clamp" ) )
{
ri.Printf( PRINT_ALL, "...using GL_SGIS_texture_edge_clamp\n" );
haveClampToEdge = qtrue;
@@ -1088,7 +1391,12 @@ void GLimp_Init( qboolean fixedFunction )
r_sdlDriver = ri.Cvar_Get( "r_sdlDriver", "", CVAR_ROM );
r_allowResize = ri.Cvar_Get( "r_allowResize", "0", CVAR_ARCHIVE | CVAR_LATCH );
r_centerWindow = ri.Cvar_Get( "r_centerWindow", "0", CVAR_ARCHIVE | CVAR_LATCH );
#ifdef __ANDROID__
// There is no desktop GL to fall back to, so don't leave it to detection
r_preferOpenGLES = ri.Cvar_Get( "r_preferOpenGLES", "1", CVAR_ARCHIVE | CVAR_LATCH );
#else
r_preferOpenGLES = ri.Cvar_Get( "r_preferOpenGLES", "-1", CVAR_ARCHIVE | CVAR_LATCH );
#endif
if( ri.Cvar_VariableIntegerValue( "com_abnormalExit" ) )
{
@@ -1187,10 +1495,18 @@ GLimp_EndFrame
Responsible for doing a swapbuffers
===============
*/
static qboolean glimp_presentsToWindow = qtrue;
void GLimp_SetPresentsToWindow( qboolean presents )
{
glimp_presentsToWindow = presents;
}
void GLimp_EndFrame( void )
{
// don't flip if drawing to front buffer
if ( Q_stricmp( r_drawBuffer->string, "GL_FRONT" ) != 0 )
// don't flip if drawing to front buffer, or if the window is not what the
// viewer is actually looking at
if ( glimp_presentsToWindow && Q_stricmp( r_drawBuffer->string, "GL_FRONT" ) != 0 )
{
SDL_GL_SwapWindow( SDL_window );
}
+27
View File
@@ -32,6 +32,9 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#include "../client/client.h"
#include "../sys/sys_local.h"
#ifdef USE_OPENXR
#include "../vr/vr_common.h"
#endif
#if !SDL_VERSION_ATLEAST(2, 0, 17)
#define KMOD_SCROLL KMOD_RESERVED
@@ -1186,6 +1189,30 @@ static void IN_ProcessEvents( void )
break;
}
#ifdef USE_OPENXR
// In a headset the window is not what is being
// presented and its size decides nothing. The render
// resolution comes from the runtime, through
// refimport_t::GetVRRenderResolution, and the
// r_custom* cvars set below are ignored - so this
// restart achieves nothing at all except destroying
// and rebuilding the GL context and the OpenXR
// session underneath a running frame loop.
//
// It is not harmless. glconfig.vidWidth/Height is the
// eye buffer, never the Android surface, so the
// equality test below can never pass and the restart
// fires every time the surface settles - about five
// seconds in. After it the engine's 2D stopped
// reaching the flat panel entirely: the menu was
// drawing perfectly (256/256 panel samples) right up
// to CL_Vid_Restart_f and drew nothing ever after.
if( VR_Enabled() )
{
break;
}
#endif
// check if size actually changed
if( cls.glconfig.vidWidth == width && cls.glconfig.vidHeight == height )
{
+84
View File
@@ -25,6 +25,9 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#include "../client/snd_public.h"
#include "../client/client.h"
#if defined(USE_OPENXR) && !defined(DEDICATED)
#include "../vr/vr_common.h"
#endif
#include "../corepp/tiki.h"
#include "../qcommon/localization.h"
#include "../qcommon/crc.h"
@@ -1734,6 +1737,82 @@ unsigned int PF_SV_Client_MaxPendingCommands(int clientNum)
return SV_Client_GetMaxPendingCommands(svs.clients + clientNum);
}
/*
==================
SV_VR_GetWeaponAim
Hands the weapon hand's aim to the game, so a shot goes where the controller is
pointing rather than where the player is looking.
Single player only, and deliberately so: the angles come from a headset attached
to this process, and a dedicated server has none. It answers false there and the
game keeps the view aim it already had.
==================
*/
static qboolean SV_VR_GetWeaponAim( vec3_t out ) {
#if defined(USE_OPENXR) && !defined(DEDICATED)
return VR_GetWeaponAim( out );
#else
return qfalse;
#endif
}
/*
==================
SV_VR_GetHeadOffset
Hands the head's offset within the play space to the game, so that a trace from
the player's eye starts where the player is actually looking from.
Single player only, for the same reason as the aim above: a dedicated server has
no headset, answers false, and the game keeps the body's eye position.
==================
*/
static qboolean SV_VR_GetHeadOffset( vec3_t out, float *baseYaw ) {
#if defined(USE_OPENXR) && !defined(DEDICATED)
return VR_GetHeadOffset( out, baseYaw );
#else
return qfalse;
#endif
}
/*
==================
SV_VR_GetClimbPull
Hands the ladder drag to the game, which is the only place that can act on it:
the climb moves the player's origin, and where the player may be moved to is a
question about the world.
Single player only, as above.
==================
*/
static qboolean SV_VR_GetClimbPull( vec3_t out ) {
#if defined(USE_OPENXR) && !defined(DEDICATED)
return VR_GetClimbPull( out );
#else
return qfalse;
#endif
}
/*
==================
SV_VR_GetThrow
Hands the weapon hand's velocity to the game, so a thrown grenade leaves along
the arm's own swing.
Single player only, as with the rest of these.
==================
*/
static qboolean SV_VR_GetThrow( vec3_t velocity, float *baseYaw ) {
#if defined(USE_OPENXR) && !defined(DEDICATED)
return VR_GetThrow( velocity, baseYaw );
#else
return qfalse;
#endif
}
/*
===============
SV_InitGameProgs
@@ -1950,6 +2029,11 @@ void SV_InitGameProgs( void ) {
import.Cvar_Find = Cvar_FindVar;
import.VR_GetWeaponAim = SV_VR_GetWeaponAim;
import.VR_GetHeadOffset = SV_VR_GetHeadOffset;
import.VR_GetClimbPull = SV_VR_GetClimbPull;
import.VR_GetThrow = SV_VR_GetThrow;
import.Client_NumPendingCommands = PF_SV_Client_NumPendingCommands;
import.Client_MaxPendingCommands = PF_SV_Client_MaxPendingCommands;
+168
View File
@@ -0,0 +1,168 @@
/*
===========================================================================
Copyright (C) 2026 the OpenMoHAA team
This file is part of OpenMoHAA source code.
OpenMoHAA source code is free software; you can redistribute it
and/or modify it under the terms of the GNU General Public License as
published by the Free Software Foundation; either version 2 of the License,
or (at your option) any later version.
OpenMoHAA source code is distributed in the hope that it will be
useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with OpenMoHAA source code; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
===========================================================================
*/
#include "../qcommon/q_shared.h"
#include "../qcommon/qcommon.h"
#include "sys_local.h"
#include "sys_android.h"
#include <pthread.h>
#include <unistd.h>
/*
There is no terminal to talk to, so the console goes to logcat instead.
Engine output arrives through CON_Print and is written directly. Anything
that bypasses it and writes to stdout or stderr - the signal handler, the
C library, third party code - would otherwise be discarded by Android, so
those two descriptors are pumped into logcat as well. Losing that output
is worse than it sounds: a diagnostic that never appears is indistinguishable
from one that was never reached.
*/
/*
==================
CON_FlushLine
logcat is line oriented, Com_Printf is not, so lines are assembled here and
emitted one at a time.
==================
*/
static char con_lineBuffer[1024];
static size_t con_lineLength;
static void CON_FlushLine(void)
{
if (!con_lineLength) {
return;
}
con_lineBuffer[con_lineLength] = '\0';
Sys_AndroidLog(con_lineBuffer);
con_lineLength = 0;
}
/*
==================
CON_StdioPump
Reads whatever was written to stdout/stderr and re-emits it, one line per
log entry.
==================
*/
static int con_stdioPipe[2] = {-1, -1};
static pthread_t con_stdioThread;
static void *CON_StdioPump(void *arg)
{
char line[1024];
size_t length = 0;
char c;
while (read(con_stdioPipe[0], &c, 1) == 1) {
if (c == '\n' || length == sizeof(line) - 1) {
line[length] = '\0';
Sys_AndroidLog(line);
length = 0;
} else if (c != '\r') {
line[length++] = c;
}
}
return NULL;
}
/*
==================
CON_Init
==================
*/
void CON_Init(void)
{
if (pipe(con_stdioPipe) == -1) {
Sys_AndroidLog("Failed to create the stdio pipe; stdout and stderr will be lost");
return;
}
// Unbuffered, so output shows up when it is written rather than when the
// process happens to flush - which on a crash is never.
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
dup2(con_stdioPipe[1], STDOUT_FILENO);
dup2(con_stdioPipe[1], STDERR_FILENO);
if (pthread_create(&con_stdioThread, NULL, CON_StdioPump, NULL) != 0) {
Sys_AndroidLog("Failed to start the stdio pump; stdout and stderr will be lost");
return;
}
pthread_detach(con_stdioThread);
}
/*
==================
CON_Shutdown
==================
*/
void CON_Shutdown(void)
{
CON_FlushLine();
}
/*
==================
CON_Input
==================
*/
char *CON_Input(void)
{
return NULL;
}
/*
==================
CON_Print
==================
*/
void CON_Print(const char *msg)
{
while (*msg) {
if (Q_IsColorString(msg)) {
// logcat has no colours, so drop the escape rather than print it
msg += 2;
continue;
}
if (*msg == '\n') {
CON_FlushLine();
} else if (*msg != '\r') {
con_lineBuffer[con_lineLength++] = *msg;
if (con_lineLength == sizeof(con_lineBuffer) - 1) {
CON_FlushLine();
}
}
msg++;
}
}
+328
View File
@@ -0,0 +1,328 @@
/*
===========================================================================
Copyright (C) 2026 the OpenMoHAA team
This file is part of OpenMoHAA source code.
OpenMoHAA source code is free software; you can redistribute it
and/or modify it under the terms of the GNU General Public License as
published by the Free Software Foundation; either version 2 of the License,
or (at your option) any later version.
OpenMoHAA source code is distributed in the hope that it will be
useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with OpenMoHAA source code; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
===========================================================================
*/
#include "../sys_local.h"
#include "../sys_android.h"
#include <android/log.h>
#include <dlfcn.h>
#include <signal.h>
#include <stdint.h>
#include <string.h>
#include <ucontext.h>
#include <unistd.h>
#include <unwind.h>
#ifndef DEDICATED
# ifdef USE_INTERNAL_SDL_HEADERS
# include "SDL.h"
# else
# include <SDL.h>
# endif
#endif
/*
Android counterpart to sys_unix_new.c.
bionic has no <execinfo.h>, so the backtrace is walked with the unwinder
directly and the addresses resolved through dladdr. This matters more here
than on the desktop: tombstoned does not reliably put a backtrace in logcat,
and when it does not, this is the only account of where the engine died.
*/
/*
==================
Sys_AndroidLog
==================
*/
void Sys_AndroidLog(const char *message)
{
__android_log_write(ANDROID_LOG_INFO, SYS_ANDROID_LOG_TAG, message);
}
/*
==================
Sys_PlatformInit_New
==================
*/
void Sys_PlatformInit_New() {
}
/*
==================
Sys_AndroidSetupPaths
==================
*/
void Sys_AndroidSetupPaths(void)
{
#ifndef DEDICATED
const char *path = SDL_AndroidGetExternalStoragePath();
char message[MAX_OSPATH + 64];
if (!path) {
Com_sprintf(message, sizeof(message),
"No external files directory (%s); the game data will not be found",
SDL_GetError());
Sys_AndroidLog(message);
return;
}
// The engine resolves relative paths against the working directory, and an
// Android process starts in "/" where it can neither read nor write.
if (chdir(path) != 0) {
Com_sprintf(message, sizeof(message), "Could not enter %s", path);
Sys_AndroidLog(message);
return;
}
Sys_SetDefaultInstallPath(path);
Com_sprintf(message, sizeof(message), "Game data directory: %s", path);
Sys_AndroidLog(message);
#endif
}
/*
==================
Sys_PrepareBackTrace
==================
*/
void Sys_PrepareBackTrace() {
}
/*
==================
Sys_UnwindCallback
Collects the program counter of each frame as the unwinder walks up the
stack.
==================
*/
typedef struct {
void **frames;
size_t count;
size_t max;
} backTraceState_t;
static _Unwind_Reason_Code Sys_UnwindCallback(struct _Unwind_Context *context, void *arg)
{
backTraceState_t *state = (backTraceState_t *)arg;
uintptr_t pc = _Unwind_GetIP(context);
if (!pc) {
return _URC_NO_REASON;
}
if (state->count >= state->max) {
return _URC_END_OF_STACK;
}
state->frames[state->count++] = (void *)pc;
return _URC_NO_REASON;
}
/*
==================
Sys_PrintBackTrace
Written straight to logcat rather than to stderr: this runs from the signal
handler, and anything still sitting in a buffer when the process aborts is
lost.
==================
*/
void Sys_PrintBackTrace() {
void *frames[128];
backTraceState_t state;
char line[1024];
size_t i;
state.frames = frames;
state.count = 0;
state.max = ARRAY_LEN(frames);
_Unwind_Backtrace(Sys_UnwindCallback, &state);
for (i = 0; i < state.count; i++) {
Dl_info info;
const char *module = "?";
const char *symbol = NULL;
uintptr_t offset = 0;
if (dladdr(frames[i], &info) && info.dli_fname) {
const char *slash = strrchr(info.dli_fname, '/');
module = slash ? slash + 1 : info.dli_fname;
symbol = info.dli_sname;
if (info.dli_saddr) {
offset = (uintptr_t)frames[i] - (uintptr_t)info.dli_saddr;
}
}
if (symbol) {
Com_sprintf(line, sizeof(line), " #%02u %p %s %s+%u",
(unsigned int)i, frames[i], module, symbol, (unsigned int)offset);
} else {
Com_sprintf(line, sizeof(line), " #%02u %p %s",
(unsigned int)i, frames[i], module);
}
Sys_AndroidLog(line);
}
}
/*
==================
Sys_AndroidDescribeAddress
Names whatever the address falls inside, so a bare pointer becomes a library
and a symbol.
==================
*/
static void Sys_AndroidDescribeAddress(const void *address, char *out, size_t outSize)
{
Dl_info info;
if (dladdr(address, &info) && info.dli_fname) {
const char *slash = strrchr(info.dli_fname, '/');
const char *module = slash ? slash + 1 : info.dli_fname;
if (info.dli_sname && info.dli_saddr) {
Com_sprintf(out, outSize, "%p %s %s+%u", address, module, info.dli_sname,
(unsigned int)((uintptr_t)address - (uintptr_t)info.dli_saddr));
return;
}
Com_sprintf(out, outSize, "%p %s+%u", address, module,
(unsigned int)((uintptr_t)address - (uintptr_t)info.dli_fbase));
return;
}
Com_sprintf(out, outSize, "%p <unmapped>", address);
}
/*
==================
Sys_AndroidCrashHandler
Reports the fault before handing over to the engine's own handler. The register
state is the part that matters: the unwinder stops at the signal frame, so the
program counter carried in the context is the only reliable account of where
the process actually died.
==================
*/
static void Sys_AndroidCrashHandler(int signum, siginfo_t *info, void *contextPtr)
{
char line[512];
char described[256];
Com_sprintf(line, sizeof(line), "Fatal signal %d at address %p", signum,
info ? info->si_addr : NULL);
Sys_AndroidLog(line);
#if defined(__aarch64__)
if (contextPtr) {
const ucontext_t *context = (const ucontext_t *)contextPtr;
Sys_AndroidDescribeAddress((const void *)context->uc_mcontext.pc, described, sizeof(described));
Com_sprintf(line, sizeof(line), " pc %s", described);
Sys_AndroidLog(line);
// x30 is the link register: where the faulting function would have
// returned to, which identifies the caller when pc is in a stripped or
// JIT-generated region.
Sys_AndroidDescribeAddress((const void *)context->uc_mcontext.regs[30], described, sizeof(described));
Com_sprintf(line, sizeof(line), " lr %s", described);
Sys_AndroidLog(line);
}
#endif
Sys_SigHandler(signum);
}
/*
==================
Sys_AndroidInstallCrashHandler
==================
*/
void Sys_AndroidInstallCrashHandler(void)
{
static char signalStack[SIGSTKSZ * 4];
const int signals[] = { SIGSEGV, SIGBUS, SIGILL, SIGFPE, SIGABRT };
struct sigaction action;
stack_t altStack;
size_t i;
// A stack overflow faults again the moment the handler pushes a frame, and
// the second fault is delivered as an unrecoverable kill with nothing
// logged. Give the handler its own stack so it survives to report.
altStack.ss_sp = signalStack;
altStack.ss_size = sizeof(signalStack);
altStack.ss_flags = 0;
sigaltstack(&altStack, NULL);
memset(&action, 0, sizeof(action));
action.sa_sigaction = Sys_AndroidCrashHandler;
action.sa_flags = SA_SIGINFO | SA_ONSTACK | SA_RESETHAND;
sigemptyset(&action.sa_mask);
for (i = 0; i < ARRAY_LEN(signals); i++) {
sigaction(signals[i], &action, NULL);
}
}
/*
==============
Sys_DebugPrint
==============
*/
void Sys_DebugPrint(const char* message) {
Sys_AndroidLog(message);
}
/*
==============
Sys_PumpMessageLoop
==============
*/
void Sys_PumpMessageLoop(void)
{
}
/*
==================
SetNormalThreadPriority
==================
*/
void SetNormalThreadPriority(void)
{
}
/*
==================
SetBelowNormalThreadPriority
==================
*/
void SetBelowNormalThreadPriority(void)
{
}
+13 -4
View File
@@ -32,6 +32,15 @@ static void* clipboard_text = NULL;
static void* game_library = NULL;
static void* cgame_library = NULL;
#ifdef __ANDROID__
// The modules ship inside the APK, where they sit on the loader's own search
// path rather than beside a binary or under fs_basepath.
#define GAME_MODULE_USE_SYSTEM_PATH qtrue
#else
#define GAME_MODULE_USE_SYSTEM_PATH qfalse
#endif
qboolean GLimp_SpawnRenderThread(void (*function)(void))
{
return qfalse;
@@ -238,12 +247,12 @@ Sys_GetGameAPI
void* Sys_GetGameAPI(void* parms)
{
void* (*GetGameAPI) (void*);
const char* gamename = "game" DLL_SUFFIX DLL_EXT;
const char* gamename = DLL_PREFIX "game" DLL_SUFFIX DLL_EXT;
if (game_library)
Com_Error(ERR_FATAL, "Sys_GetGameAPI without calling Sys_UnloadGame");
game_library = Sys_LoadDll(gamename, qfalse);
game_library = Sys_LoadDll(gamename, GAME_MODULE_USE_SYSTEM_PATH);
//Still couldn't find it.
if (!game_library) {
@@ -287,12 +296,12 @@ Sys_GetCGameAPI
void* Sys_GetCGameAPI(void* parms)
{
void* (*GetCGameAPI) (void*);
const char* gamename = "cgame" DLL_SUFFIX DLL_EXT;
const char* gamename = DLL_PREFIX "cgame" DLL_SUFFIX DLL_EXT;
if (cgame_library)
Com_Error(ERR_FATAL, "Sys_GetCGameAPI without calling Sys_UnloadCGame");
cgame_library = Sys_LoadDll(gamename, qfalse);
cgame_library = Sys_LoadDll(gamename, GAME_MODULE_USE_SYSTEM_PATH);
//Still couldn't find it.
if (!cgame_library) {
+44
View File
@@ -0,0 +1,44 @@
/*
===========================================================================
Copyright (C) 2026 the OpenMoHAA team
This file is part of OpenMoHAA source code.
OpenMoHAA source code is free software; you can redistribute it
and/or modify it under the terms of the GNU General Public License as
published by the Free Software Foundation; either version 2 of the License,
or (at your option) any later version.
OpenMoHAA source code is distributed in the hope that it will be
useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with OpenMoHAA source code; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
===========================================================================
*/
#pragma once
// The tag every log entry is filed under; `adb logcat -s openmohaa` shows
// only ours.
#define SYS_ANDROID_LOG_TAG "openmohaa"
// Writes a single line to logcat. Safe to call before the engine has started
// and from a signal handler.
void Sys_AndroidLog(const char *message);
// Installs handlers for the fatal signals that report the faulting address and
// program counter. The generic Unix handler cannot: it is installed with
// signal(), so it never sees siginfo, and _Unwind_Backtrace cannot walk out of
// the kernel's signal trampoline - which leaves a crash reported as four frames
// of the handler itself and nothing about where the fault actually happened.
void Sys_AndroidInstallCrashHandler(void);
// Points the install path at the app's external files directory and makes it
// the working directory. Must run before Com_Init, which is where FS_Startup
// reads the install path. A no-op in the dedicated server, which has no
// activity to ask.
void Sys_AndroidSetupPaths(void);
+25 -3
View File
@@ -48,6 +48,10 @@ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#include "sys_local.h"
#include "sys_loadlib.h"
#ifdef __ANDROID__
#include "sys_android.h"
#endif
#include "../qcommon/q_shared.h"
#include "../qcommon/qcommon.h"
@@ -835,6 +839,14 @@ int main( int argc, char **argv )
Sys_SetBinaryPath( Sys_Dirname( argv[ 0 ] ) );
Sys_SetDefaultInstallPath( DEFAULT_BASEDIR );
#ifdef __ANDROID__
// There is no meaningful binary path on Android - argv[0] is "app_process"
// and the process starts in "/". Point the engine at the app's own external
// files directory instead, which is where the user side-loads the game data
// and the only place it can also write.
Sys_AndroidSetupPaths();
#endif
// Concatenate the command line for passing to Com_Init
for( i = 1; i < argc; i++ )
{
@@ -875,16 +887,26 @@ int main( int argc, char **argv )
#endif
CON_Init( );
Com_Init( commandLine );
Sys_InitEx(); // Added in OPM
NET_Init( );
// Installed before Com_Init rather than after it: startup is exactly when
// renderer and filesystem bring-up crash on a new platform, and a fault
// there used to die with no backtrace at all.
#ifdef __ANDROID__
// Installs the fatal signals itself, with siginfo and a private stack so a
// crash can report where it happened rather than only that it happened.
Sys_AndroidInstallCrashHandler();
#else
signal( SIGILL, Sys_SigHandler );
signal( SIGFPE, Sys_SigHandler );
signal( SIGSEGV, Sys_SigHandler );
#endif
signal( SIGTERM, Sys_SigHandler );
signal( SIGINT, Sys_SigHandler );
Com_Init( commandLine );
Sys_InitEx(); // Added in OPM
NET_Init( );
#ifdef __EMSCRIPTEN__
emscripten_set_main_loop( Com_Frame, 0, 1 );
#else
+344
View File
@@ -0,0 +1,344 @@
/*
===========================================================================
Copyright (C) 2026 the OpenMoHAA team
This file is part of OpenMoHAA source code.
OpenMoHAA source code is free software; you can redistribute it
and/or modify it under the terms of the GNU General Public License as
published by the Free Software Foundation; either version 2 of the License,
or (at your option) any later version.
OpenMoHAA source code is distributed in the hope that it will be
useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with OpenMoHAA source code; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
===========================================================================
*/
#pragma once
#include "../qcommon/q_shared.h"
#ifdef __cplusplus
extern "C" {
#endif
#define VR_EYE_LEFT 0
#define VR_EYE_RIGHT 1
#define VR_MAX_EYES 2
/*
The headset's view of a frame.
Angles are the engine's (pitch, yaw, roll) in degrees; origin is in engine
units. The four tangents describe the eye's frustum: OpenXR gives an
asymmetric field of view per eye, and using a symmetric approximation instead
misaligns the two images enough to be uncomfortable, so they are carried
through to the projection matrix rather than reduced to a single fov value.
*/
typedef struct {
vec3_t origin;
// Forward, left and up, in the engine's convention - the same layout as
// refdef_t's viewaxis. Kept as axes rather than pitch/yaw/roll because the
// round trip through Euler angles loses the distinction between rotations
// that differ only near the poles, and reintroduces it as a twist.
vec3_t axis[3];
float tanLeft;
float tanRight;
float tanUp;
float tanDown;
} vrEyeView_t;
// Brings up the OpenXR instance and picks a system. Must run before the GL
// context exists, and returns false if there is no headset or no runtime, in
// which case the engine carries on as an ordinary flat application.
qboolean VR_Init(void);
void VR_Shutdown(void);
// True once the instance is up; the engine uses this to decide whether to
// render in stereo at all.
qboolean VR_Enabled(void);
// The session and its swapchains are bound to the GL context that is current
// when they are created, so both are torn down and rebuilt whenever the engine
// restarts the renderer.
void VR_CreateSession(void);
void VR_DestroySession(void);
// The per-eye render target size the runtime asks for.
void VR_GetRenderResolution(int *width, int *height);
// Begins a frame and updates the tracked poses. False means skip rendering
// (inactive session, hidden frame, or unavailable tracking). Still call
// VR_SubmitFrame to end any begun frame with no layers.
qboolean VR_BeginFrame(void);
// Bracket each eye's rendering. Between them the engine draws its scene into
// the framebuffer VR_PrepareEye bound.
// False if the swapchain image is not ready; skip drawing and finishing it.
qboolean VR_PrepareEye(int eye);
void VR_FinishEye(int eye);
// True when the frame is flat content - menus, the loading screen, a cinematic
// - which is shown on a quad fixed in front of the viewer rather than rendered
// into both eyes. Drawing screen space content identically into two eyes gives
// it zero disparity while each eye still reprojects through its own asymmetric
// frustum, so the two images never line up and the result cannot be fused.
qboolean VR_UseScreenLayer(void);
// Bracket the single pass that draws the flat content, in place of the two eye
// passes.
void VR_PrepareScreenLayer(void);
void VR_FinishScreenLayer(void);
// The panel on the player's wrist, which carries the HUD and any in game menu
// while the world is being drawn behind it. Shown by raising the off hand and
// turning it towards the face, the way a watch is read.
qboolean VR_WristPanelEnabled(void);
qboolean VR_WristPanelVisible(void);
void VR_PrepareWristPanel(void);
void VR_FinishWristPanel(void);
// Hands the finished images to the compositor.
void VR_SubmitFrame(void);
// Where the headset is, for the frame that VR_BeginFrame set up.
void VR_GetEyeView(int eye, vrEyeView_t *view);
// Reads the controllers and, while flat content is on screen, points the
// engine's cursor at wherever the player is aiming on the panel.
void VR_UpdateInput(void);
/*
What the player is doing, for the client to turn into a usercmd.
*/
typedef struct {
qboolean valid;
float moveForward; // -1..1, left stick
float moveRight;
float turn; // -1..1, right stick
// How far the head moved this frame, in engine units, in the play space's
// own frame. Fed into the usercmd as movement so that walking about the room
// takes the character with it - see CL_VRMove.
float stepForward;
float stepRight;
float headYaw; // degrees, relative to where they started
float headPitch;
// Where each hand points, same reference as the head. The off hand steers
// walking so the player can look around without veering.
float offhandYaw;
float weaponYaw;
float weaponPitch;
// Separately, because they are used for different things and either hand can
// drop out on its own - the off hand steering walking must not stop working
// because the weapon hand went out of view.
qboolean offhandTracked;
qboolean weaponTracked;
} vrInput_t;
qboolean VR_GetInput(vrInput_t *input);
/*
Where the weapon hand is pointing, for the game to fire along.
Not an absolute direction. The controller's own yaw is meaningless to the game -
the player can turn on the spot with the stick, and the play space has an
arbitrary forward - so what travels is how far the hand *leads the head*, which
the game then adds to the view yaw it already has. That is what RTCWQuest does
(rtcw/src/game/g_weapon.c:1904):
viewang[YAW] = ent->client->ps.viewangles[YAW]
+ (gVR->weaponangles[YAW] - gVR->hmdorientation[YAW]);
out[PITCH] the controller's pitch, absolute - the head contributes nothing
to it, and blending the two would fight the player's own wrist
out[YAW] degrees the hand leads the head by
out[ROLL] unused; roll does not change a forward vector
False when there is no headset, no tracked weapon hand, or VR is off - the
caller must keep its existing aim in that case rather than firing at zero.
*/
qboolean VR_GetWeaponAim(vec3_t out);
/*
Where the head is within the play space, from the tracking origin, in engine
units and the engine's frame, with the base yaw the caller needs to rotate it
into the body's frame:
bodyAngles = (0, viewYaw - baseYaw, 0);
eye = playerStateEyePos + offset[i] * bodyAxis[i];
That is the same composition R_VRComposeView uses to make the camera, and it
has to be, or the answer is a point the player is not looking from.
For the game rather than the renderer: the eye position in the player state is
the body's, and the camera has the play space offset on top of it. A trace from
the body's eye is aimed from the wrong place whenever the two have come apart,
which is precisely when the body is against something and the head is not.
False when there is no headset, and the caller keeps the eye position it has.
*/
qboolean VR_GetHeadOffset(vec3_t out, float *baseYaw);
/*
Whether the player is on a ladder. The client reads it out of the player state
and tells the VR layer, which cannot see the player state and needs to know:
the grips hold rungs there instead of steadying the rifle and reloading.
*/
void VR_SetClimbing(qboolean climbing);
/*
Whether the player has a weapon in hand. The client reads it out of the player
state; the VR layer uses it to tell a hand reaching for a holster from a hand
bringing something back to one.
*/
void VR_SetArmed(qboolean armed);
/*
The weapon hand's velocity, in engine units a second and the play space's
frame, with the base yaw needed to rotate it into the world. For throwing:
a grenade should leave the hand going where the hand was going.
False when the hand is not moving enough to be a throw.
*/
qboolean VR_GetThrow(vec3_t velocity, float *baseYaw);
/*
How far the hands holding the ladder have dragged since this was last asked, in
engine units and the engine's frame, measured in the play space.
The body moves the other way by this much, which is what makes it a drag: pull
down and you rise by the same distance, stop and you stop. Both hands averaged
when both are holding on, so gripping with two hands does not climb twice as
fast.
False when nothing is being pulled, and the caller leaves the player where the
climb animation put them.
*/
qboolean VR_GetClimbPull(vec3_t out);
/*
The weapon wheel's state, for cgame to draw. True while it is held open, with
the segment the controller is pointing at in selection - or -1 for the dead
zone in the middle, which selects nothing and leaves the weapon in hand.
names is filled with the class name of each segment, clockwise from straight
up, so the caller labels them without knowing what they are.
*/
qboolean VR_GetWheel(int *selection, const char **names, int maxNames, int *count);
/*
Where the weapon hand is, for the view model to hang off.
Reports the weapon hand in exactly the frame VR_GetHandPose reports the other
one - measured from the tracking origin, not from the head - so both hands are
composed the same way and the long note below applies to both. What is different
is only the angles: these carry the weapon's aim, so the two handed hold has
already had its say on them, which is right for the thing being held and wrong
for the hand that is not holding it.
The reference keeps its equivalent (calculated_weaponoffset) relative to the
head, but then rotates it and replaces its height in convertFromVR and the -64
rebase, which comes to the same place by a longer road. Measuring from the
tracking origin does it in one step. What must never happen is half of each: a
head-relative offset composed as though it were play-space puts the hand at
vieworg + R*(hand - head) while the camera is at vieworg + R*head, so the head's
own movement is counted twice and every hand movement comes out amplified.
*/
qboolean VR_GetWeaponPose(vec3_t trackingOffset, vec3_t angles, float *baseYaw);
/*
Where one hand is, for something to be drawn on it. hand is 0 for the left and
1 for the right, which is the order OpenXR's subaction paths are set up in here
and has nothing to do with which one holds the weapon.
trackingOffset is the hand measured from the **tracking origin**, in engine units
and the engine's frame - not from the head. That is deliberate and it is the
whole reason this is not shaped like VR_GetWeaponPose.
The renderer does not take its camera from cg.refdef.vieworg as cgame leaves it.
R_VRComposeView adds the head's play space offset to it, rotated into the body's
frame (tr_vr.c). So the camera the player actually looks through is
vieworg + bodyAxis * headPlaySpaceOffset
and cgame never sees the second term. Anything anchored on cg.refdef.vieworg
alone is therefore displaced by exactly the head's offset within the play space -
and since turning the head swings it about the neck, every look around moves it.
Reporting the hand in the play space lets the caller compose it the same way the
renderer composes the camera, which puts the two in the same place by
construction:
bodyAngles = (0, viewYaw - baseYaw, 0)
handWorld = vieworg + bodyAxis * trackingOffset
baseYaw is how much of the head's heading the client has already written into the
view angles; subtracting it is what leaves the body's frame rather than the
head's. It is out here because cgame has no other way to know it.
angles carry the controller's own pitch and roll, and in YAW how far the hand
leads the head. It is the *hand's* pose: the two handed hold and the per weapon
adjustment move the weapon, not the hand that is not holding it.
False when there is no headset or that hand is not tracked - a controller that
has been put down keeps its last drawn position rather than snapping to the
player's feet, so the caller should skip drawing instead of using zeroes.
*/
qboolean VR_GetHandPose(int hand, vec3_t trackingOffset, vec3_t angles, float *baseYaw);
// How much of the head's heading the client has already written into the game's
// view angles. The renderer takes it back off before composing the headset onto
// the game's camera, so the same rotation is not applied twice.
void VR_SetBaseYaw(float yaw);
// The renderer's own split of the frame, for the timing report: how long was
// spent building the scene against how long was spent issuing it.
void VR_TraceRenderTimes(int frontEndMsec, int backEndMsec);
// How long the game spent building the frame, against how long the renderer
// spent issuing it. The first covers everything cgame does per frame, which is
// far more than drawing.
void VR_TraceSceneTimes(int sceneMsec, int issueMsec);
// Inside the scene: the 3D half against the flat half. Splits cgame's own per
// frame work from the 2D path, which flushes the render command buffer once per
// string drawn and is a suspect in its own right.
void VR_TraceViewTimes(int worldMsec, int hudMsec);
// Of the flat half, how much is the game's own HUD as against the engine's
// overlays around it.
void VR_TraceHudTimes(int cgameMsec);
// Counters for the paths that draw the flat content, so a path that is never
// reached can be told apart from one that is reached and draws nothing.
#define VRTRACE_DRAW2D 0 // View3D::Draw2D entered
#define VRTRACE_CGAME_HUD 1 // cge->CG_Draw2D entered
#define VRTRACE_UPDATEVIEWS 2 // uWinMan.UpdateViews entered
#define VRTRACE_VIEW3D 3 // View3D::Draw entered
#define VRTRACE_COUNT 4
void VR_TraceEvent(int which);
// The client's idea of the frame, so the report can say what mode it was in.
void VR_TraceState(int drawMode, int hudPass, int noMenus);
// The flat pass broken down further: the fade and letterbox overlays, the
// centre print, and the sound/net/subtitle overlays, against the set2D that
// precedes them all.
void VR_TraceHudParts(int setup, int fades, int prints, int overlays, int tail);
#ifdef __cplusplus
}
#endif
+5546
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -42,7 +42,7 @@ DOXYFILE_ENCODING = UTF-8
# title of most generated pages and in a few other places.
# The default value is: My Project.
PROJECT_NAME = "OpenMoHAA"
PROJECT_NAME = "OpenMoHAA VR"
# The PROJECT_NUMBER tag can be used to enter a project or revision number. This
# could be handy for archiving the generated documentation or if some version
@@ -54,7 +54,7 @@ PROJECT_NUMBER = "$(PROJECT_VERSION_NUMBER)"
# for a project that appears at the top of each page and should give viewer a
# quick idea about the purpose of the project. Keep the description short.
PROJECT_BRIEF =
PROJECT_BRIEF = "Standalone Medal of Honor: Allied Assault VR targeting Meta Quest and Steam Frame"
# With the PROJECT_LOGO tag one can specify a logo or an icon that is included
# in the documentation. The maximum height of the logo should not exceed 55
+31 -18
View File
@@ -1,25 +1,38 @@
# Home
# OpenMoHAA VR documentation
Welcome to the [OpenMoHAA](https://www.openmohaa.org/) documentation. Here you'll find resources to help you get started and troubleshoot as needed.
OpenMoHAA VR is a standalone **Medal of Honor: Allied Assault** VR port targeting **Meta Quest and Steam Frame**. Quest 3 is the current tested headset. Steam Frame is a development target with no published build yet.
## 🚀 Getting started
## Start here
- 📦 [Installing OpenMoHAA](markdown/01-intro/01-installation.md)
- ▶️ [How to play: Launching the game, expansions & file locations](markdown/02-running/01-running.md)
- ⚙️ [Game settings & configuration](markdown/03-configuration/01-configuration.md)
- 🌐 [Setting up a game server](markdown/02-running/02-running-server.md)
- ❓ [FAQ & Troubleshooting](markdown/02-running/03-faq.md)
- [Project overview and platform status](../README.md)
- [VR downloads](https://github.com/bearstonem/openmohaa-vr/releases)
- [Quest installation and Android builds](markdown/04-coding/03-android.md)
- [Port status and known issues](../VR_PORT_STATUS.md)
- [Report a VR issue](https://github.com/bearstonem/openmohaa-vr/issues)
## 🛠️ Coding & Scripting
The APK contains only the engine. Supply your own original game data and install the release's `autoexec.cfg`, including when upgrading. That config disables development autostart and applies the current dynamic-lighting workaround.
- 💻 [Building from source](markdown/04-coding/01-compiling.md)
- 📝 [Code & Scripting reference](markdown/04-coding/02-coding.md)
- 📜 [Contributing guidelines](https://github.com/openmoh/openmohaa/blob/main/CONTRIBUTING.md)
## Working on the port
## 📚 Resources
- [Contributing to OpenMoHAA VR](../CONTRIBUTING.md)
- [Source layout](../LAYOUT.md)
- [Quest renderer notes](../VR_RENDERER_HANDOFF.md)
- [gl4es integration](markdown/04-coding/04-gl4es.md)
- [Dynamic-lighting investigation](markdown/04-coding/05-dlights.md)
- [Reusable Quest porting guide](../QUEST_PORTING_GUIDE.md)
- 🌐 [MOH-DB](https://www.moh-db.com/) - A large archive of maps, mods, tools, and tutorials for Medal of Honor: Allied Assault
- 📂 [ModDB](https://www.moddb.com/games/medal-of-honor-allied-assault) - Mods, updates, and community content
- 📂 [GameBanana](https://gamebanana.com/games/720) - Skins, mods, and custom content
- 🕹️ [333networks](https://master.333networks.com/g) - Online server list used by OpenMoHAA
- 💬 [OpenMoHAA Discord](https://discord.gg/NYtH58R) - Ask questions, get support, or join the community
The VR runtime currently depends on Android, EGL, and OpenGL ES. Steam Frame needs platform integration and testing; compiling the desktop engine alone does not provide a Steam Frame VR build.
## Inherited engine reference
These documents cover the underlying OpenMoHAA engine and its desktop tools. Their platform-specific release links and compatibility notes describe upstream capabilities, not validation of the headset port.
- [Desktop installation](markdown/01-intro/01-installation.md)
- [Desktop engine builds](markdown/04-coding/01-compiling.md)
- [Game data, expansions, and launch options](markdown/02-running/01-running.md)
- [Game settings](markdown/03-configuration/01-configuration.md)
- [Server configuration](markdown/02-running/02-running-server.md)
- [Scripting reference](markdown/04-coding/02-coding.md)
- [Engine FAQ](markdown/02-running/03-faq.md)
The [upstream OpenMoHAA project](https://github.com/openmoh/openmohaa) maintains the original engine. Use this fork's issue tracker for VR behavior and headset-specific bugs.
+6 -2
View File
@@ -1,5 +1,11 @@
# Installation guide
This is the inherited **desktop OpenMoHAA engine** installation reference. For
OpenMoHAA VR on Meta Quest, use the [Quest installation guide](../04-coding/03-android.md)
and [VR releases](https://github.com/bearstonem/openmohaa-vr/releases). Steam Frame
is a development target with no release yet. The desktop downloads below are
maintained by upstream and do not include this fork's VR port.
## Before you start
The game should be able to run on any modern hardware. You can verify the [System requirements](03-requirements.md).
@@ -29,7 +35,6 @@ If you already have MOH:AA from a CD, make sure to [Install official patches](#i
2. Then check the "**Start in**" field, that's your game folder.
![](../../assets/screenshots/install_guide/windows_installation_shortcut.png)
![](screenshots/install_guide/windows_installation_shortcut.png)
Alternatively, try one of these common locations:
- C:\GOG Games\Medal of Honor - Allied Assault War Chest
@@ -49,7 +54,6 @@ If not there, you'll need to search where you installed it.
To do that, **right-click each file**, then choose **Send to → Desktop (create shortcut)**.
![](screenshots/install_guide/windows_installation_sendto.png)
![](../../assets/screenshots/install_guide/windows_installation_sendto.png)
Now you can launch any version of the game directly from your desktop using OpenMoHAA.
+6 -1
View File
@@ -1,5 +1,10 @@
# Frequently Asked Questions
This FAQ describes the inherited desktop engine. For headset setup and known
VR issues, see the [Quest guide](../04-coding/03-android.md) and
[VR port status](../../../VR_PORT_STATUS.md). Report issues with this port in
[OpenMoHAA VR](https://github.com/bearstonem/openmohaa-vr/issues).
<details>
<summary>The game won't start or crashes immediately. What should I check?</summary>
@@ -86,7 +91,7 @@ However, if `grenzuebergang_KE.pk3` is placed in the game installation folder (`
`Pak1.pk3` contains the original `scripts/effects.shader` where the impact effect is declared; therefore, the player does not experience any issue, as the original file is "loaded" after the custom file. |
### 2. Create a new issue:
If changing the file precedences has no effect on the glitch you discovered, you have probably found a new bug in the OpenMoHAA engine, so please <a href="https://github.com/openmoh/openmohaa/issues/new/choose">create a new issue</a> for the developers.
If changing the file precedences has no effect, [report the issue in OpenMoHAA VR](https://github.com/bearstonem/openmohaa-vr/issues/new/choose). Include the mod, map, headset, release, and reproduction steps. If you also tested the upstream desktop engine, state whether it reproduces there.
</details>
---
+5
View File
@@ -1,5 +1,10 @@
# Compiling
This is the inherited **desktop engine** build reference. For the standalone
Quest port, follow the [Android/Quest build guide](03-android.md). Steam Frame
integration is still pending; these desktop instructions do not produce a
Steam Frame VR build. The architecture notes below describe upstream OpenMoHAA.
OpenMoHAA supports any architecture, thanks to the CMake build system and cross-platform code/libraries. While the most common little-Endian architectures (arm, aarch64, x86, x86_64, ppc64le) have been tested and confirmed to work, big-Endian architectures should be supported as well; however, they remain untested.
The following tools are required for all platforms:
+150
View File
@@ -0,0 +1,150 @@
# Building OpenMoHAA VR for Meta Quest
OpenMoHAA VR runs **Medal of Honor: Allied Assault** in standalone VR through
OpenXR. This guide builds the `arm64-v8a` Android APK for the Meta Quest family;
Quest 3 is the tested headset. Other Quest models need validation. Steam Frame
is a planned target; the current VR runtime implementation is Android-only.
You need the original retail Allied Assault game data. It is not included in
this repository or the APK. Prebuilt packages are available from
[OpenMoHAA VR releases](https://github.com/bearstonem/openmohaa-vr/releases).
> On Android, the fixed function renderer runs through gl4es, which maintains
> its own GL state. For rendering problems, read
> [the gl4es boundary](04-gl4es.md) and the
> [VR port status](../../../VR_PORT_STATUS.md).
## What you need
| Tool | Version |
|---|---|
| Android NDK | r26 or later |
| Android SDK | platform 34, build-tools 34, platform-tools (`adb`) |
| JDK | 17 |
| Gradle | 8.0 or later |
| CMake | 3.25 or later |
| Ninja, flex, bison | installed on the build host |
Set `ANDROID_NDK_HOME` and `ANDROID_HOME` to your installed NDK and SDK paths.
Enable developer mode and USB debugging on the headset, and authorize the host
when prompted. Run the following commands from the repository root unless a
step says otherwise.
## 1. Build the engine
CMake builds the native libraries; Gradle packages them afterward.
```sh
cmake -B build-gl1 -G Ninja \
-DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" \
-DANDROID_ABI=arm64-v8a \
-DANDROID_PLATFORM=android-29 \
-DANDROID_STL=c++_shared \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SERVER=OFF \
-DBUILD_RENDERER_GL1=ON -DBUILD_RENDERER_GL2=OFF -DUSE_GL4ES=ON
cmake --build build-gl1 -j"$(nproc)"
```
`ANDROID_STL=c++_shared` lets the APK's native libraries share one C++ runtime.
Android enables `USE_OPENXR` automatically. The default renderer is
`renderergl1` through gl4es; the alternative GLES renderer can be selected with
`-DBUILD_RENDERER_GL1=OFF -DBUILD_RENDERER_GL2=ON -DUSE_GL4ES=OFF`.
SDL2, OpenAL Soft, gl4es, and the Khronos OpenXR loader are downloaded and built
from source. Existing source trees can be supplied with `SDL2_SOURCE_PATH`,
`OPENAL_SOURCE_PATH`, `GL4ES_SOURCE_PATH`, and `OPENXR_SOURCE_PATH` CMake options.
Android links the renderer into the client, disables HTTP downloads and QVM
builds, and stages the APK libraries in `build-gl1/apk-libs/arm64-v8a/`.
## 2. Package the APK
```sh
cd misc/android
gradle assembleDebug -PnativeLibsDir=../../build-gl1/apk-libs
cd ../..
```
The output is `misc/android/app/build/outputs/apk/debug/app-debug.apk`. Keep the
`nativeLibsDir` override: the checked-in Gradle default names a different build
directory. For another build location, supply its absolute `apk-libs` path.
Gradle reads `android-build.properties` beside `apk-libs` to find the SDL Java
classes belonging to the SDL library CMake built. Do not mix those classes with
a different SDL version. After rebuilding, verify that the APK is newer than the
staged native libraries so you install the current code.
## 3. Install the APK and retail data
```sh
adb install -r misc/android/app/build/outputs/apk/debug/app-debug.apk
adb shell am force-stop org.openmoh.openmohaa
```
The Android package ID remains `org.openmoh.openmohaa`, including for upgrades.
Launch the app once from inside the headset to initialize its external files
directory, then close it. Game data belongs in:
```text
/sdcard/Android/data/org.openmoh.openmohaa/files/main/
```
Copy **all** Allied Assault `.pk3` archives from your retail installation's
`main/` directory. Replace the example source path below with your own:
```sh
adb shell mkdir -p /sdcard/Android/data/org.openmoh.openmohaa/files/main
adb push /path/to/retail/main/*.pk3 \
/sdcard/Android/data/org.openmoh.openmohaa/files/main/
```
Do not pre-create or copy `main/save` or `main/configs` with `adb`. Let the app
create these directories so it can write saves and settings. Directories
created by the shell can leave the app unable to write; changing their mode
does not fix ownership. Keep existing saves when upgrading. A save-directory
permission failure can appear as a black screen on New Game because the first
autosave fails.
## 4. Install the VR configuration and launch
Copy the supplied configuration after installing or upgrading:
```sh
adb push misc/android/autoexec.cfg \
/sdcard/Android/data/org.openmoh.openmohaa/files/main/autoexec.cfg
adb shell am force-stop org.openmoh.openmohaa
```
`autoexec.cfg` is external game configuration, not an APK asset. Reinstalling
with `adb install -r` preserves the previous file, so replace it explicitly.
Back up any personal edits before replacing it. The supplied configuration
starts at the normal menu with cheats, automatic map loading, and diagnostics
disabled. Keep its `r_worldDlights 0` setting: it works around the unresolved
[dynamic-light darkening defect](05-dlights.md), and the APK alone does not
apply that workaround.
Launch **OpenMoHAA VR** from inside the headset (the published 0.2.0 APK is still
named **OpenMoHAA** in the launcher). Do not use `adb shell am start`;
it has intermittently left Quest in Meta's loading environment. Force-stop
after each APK update because `adb install -r` can leave the old process running.
## Logging and implementation
Capture engine output before reproducing a problem:
```sh
adb logcat -s openmohaa:V > openmohaa-vr.log
```
The engine redirects stdout and stderr to the same tag. Its Android crash
handler reports the fault address and ARM64 `pc`/`lr` registers as well as an
unwind trace. If logcat has no crash report, `adb shell dumpsys activity exit-info`
can still identify the termination signal.
`SDLActivity` loads `libSDL2.so` and `libopenmohaa.so`, then calls the engine's
`SDL_main`. The APK also carries OpenAL, gl4es, the OpenXR loader, and
`libc++_shared.so`; the engine loads `libgame.so` and `libcgame.so` at runtime.
OpenXR uses SDL's EGL context and supplies the eye swapchain images. The
[port status](../../../VR_PORT_STATUS.md) records the architecture, known
limitations, and which changes still need headset validation.
+177
View File
@@ -0,0 +1,177 @@
# The gl4es boundary
On Android the fixed function renderer (`renderergl1`) does not talk to the GPU.
It talks to [gl4es](https://github.com/ptitSeb/gl4es), which translates desktop
OpenGL 1.x into OpenGL ES. The engine never learns this: gl4es reports itself as
`2.1 gl4es wrapper 1.1.6`, so the renderer takes its desktop path and gl4es
turns `glBegin`, `glMatrixMode` and the rest into ES underneath.
That works. What is not obvious, and what has now cost several days of debugging
across separate sessions, is that **gl4es keeps its own copy of GL state, and
that copy can disagree with the driver.** Everything below follows from that one
fact.
Read this before debugging anything that looks like "the geometry is correct and
nothing is drawn".
---
## 1. gl4es answers state queries from its own tables
`glGetIntegerv(GL_CULL_FACE_MODE)`, `GL_FRONT_FACE`, `GL_MODELVIEW_MATRIX` and
friends are answered out of gl4es's `glstate`, not by asking the driver
(`src/gl/getter.c`). `glGetString` is the same - `GL_VERSION`, `GL_VENDOR` and
`GL_RENDERER` come from gl4es's constants without the driver being consulted.
So this proves nothing:
```c
qglGetIntegerv( GL_CULL_FACE_MODE, &mode );
/* compare mode against what the engine set */
```
The engine set that value *through gl4es*. Reading it back through gl4es returns
the engine's own value. It agrees by construction whatever the hardware is doing.
Two measurements taken this way were reported as findings and were worthless -
one of them ("GL and the engine agree on culling") was actively concealing the
bug being hunted.
**To ask the hardware, resolve the entry point out of the driver:**
```c
void *lib = dlopen( "libGLESv3.so", RTLD_NOW | RTLD_LOCAL );
void (*driverGetIntegerv)(GLenum, GLint *) = dlsym( lib, "glGetIntegerv" );
```
A temporary `r_traceSurf` probe did this - printing gl4es's answer and the
driver's side by side - and found the cull bug in one run, after many runs that
measured nothing. It has since been removed; the technique is the point, and it
is four lines to put back.
## 2. gl4es drops calls it judges redundant
Each of these returns early when the requested value matches gl4es's copy:
| Call | Where |
|---|---|
| `glCullFace` | `src/gl/face.c:14` |
| `glDepthMask`, `glDepthFunc`, `glDepthRangef` | `src/gl/depth.c` |
| `glBlendFunc` | `src/gl/blend.c` |
| `glColorMask` | `src/gl/gl4es.c` |
| `glViewport`, `glScissor` | `src/gl/raster.c:61` |
| `glEnable`/`glDisable` for `GL_CULL_FACE`, `GL_DEPTH_TEST`, `GL_STENCIL_TEST`, `GL_POLYGON_OFFSET_FILL` | `src/gl/enable.c`, `proxy_glEnable` |
`GL_SCISSOR_TEST` is *not* filtered - it falls through `proxy_glEnable`'s default
branch to the driver every time.
**Once gl4es's copy drifts from the driver, the engine can never correct it.**
It asks for the right value, gl4es compares against the wrong copy, calls it a
no-op, and the driver never hears. No error is raised. Nothing looks wrong from
inside the engine, because the engine's own state tracker and gl4es's tracker
agree with each other - they are just both wrong about the hardware.
## 3. Anything the VR layer sets natively must go through gl4es
`code/vr/vr_openxr.c` links `libGLESv3` directly, so a bare `glViewport` there
reaches the driver without gl4es seeing it. gl4es's copy is then stale, and by
§2 the engine's next request for that value is silently dropped.
This is inverted from the reference. RTCWQuest's ndk-build puts gl4es first in
its link order, so *their* VR layer's GL calls go through gl4es by default and
they bypass it explicitly only where they must. Here the default is the other
way round, so the routing has to be done by hand.
The VR layer therefore wraps every state call gl4es caches - `VR_Viewport`,
`VR_GLEnable`, `VR_GLDisable`, `VR_DepthMask`, `VR_DepthFunc`, `VR_BlendFunc`,
`VR_ColorMask`, `VR_CullFace`, `VR_Scissor`, `VR_BindFramebuffer` - and resolves
them out of `libgl4es.so` by name.
**Three things deliberately stay with the driver**, and must not be routed:
- the swapchain texture attachment (`glFramebufferTexture2D`) - gl4es cannot
attach a texture the OpenXR runtime created;
- the blit's `GL_READ_FRAMEBUFFER` / `GL_DRAW_FRAMEBUFFER` bindings - gl4es
treats `GL_READ_FRAMEBUFFER` as a note to itself and returns without binding
(`framebuffers.c:238`), which silently empties the blit;
- the VR layer's own pipeline: its shader program, vertex attributes, uniforms
and draw calls. gl4es has no business knowing about those.
## 4. Setting state that must not be dropped
Where a value has to reach the driver regardless of what gl4es believes, name a
throwaway value first so the real one cannot match a stale cache. `GL_Cull` in
`renderergl1/tr_backend.c`:
```c
#ifdef USE_GL4ES
qglCullFace( GL_FRONT_AND_BACK ); /* cannot be mistaken for a no-op */
#endif
qglCullFace( face );
```
Nothing is drawn between the two calls, so the throwaway value is harmless.
---
## 5. What this actually cost
Three bugs on this boundary, each of which presented as "the renderer is
correct and nothing is drawn":
**The framebuffer binding.** gl4es renders from its own table, not the driver's
binding, so a framebuffer created by `glGenFramebuffers` on the driver is not in
that table - `gl4es_glBindFramebuffer` raises `GL_INVALID_VALUE`, returns
without binding, and leaves gl4es drawing into the 16x16 pbuffer. Measured as
`fbo: gl4es says 0, driver says 10`.
**The cull face.** gl4es's copy said `GL_FRONT`; the driver was culling
`GL_BACK`. The engine asked for `GL_FRONT` every frame and gl4es dropped it as
redundant. Quake's world polygons are back facing in GL's terms - which is why
`CT_FRONT_SIDED` culls `GL_FRONT` in the first place - so the hardware was
removing precisely the faces that should have been visible.
The symptom was **the entire world invisible while models rendered perfectly**,
because model shaders are `CT_TWO_SIDED` and never enable culling at all.
Inverting the culled face did not help either: asking for `GL_BACK` matched what
the driver was already doing. Only disabling culling outright brought the world
back, which made it look like a winding fault for a very long time.
**The viewport.** Set natively by the VR layer, cached by gl4es, so the engine's
next request for the same value was dropped.
### The shape to recognise
> Geometry submitted. Vertex data correct. Indices in range. Matrices correct.
> On screen, unclipped, unculled by the frustum. No GL error. Nothing drawn.
That is what a dropped state call looks like from inside the engine, every time.
When you see it, **stop measuring the engine and ask the driver.**
---
## 6. Two rules that are not about gl4es
Learned the hard way alongside the above, and they apply to any device work
here.
**A probe must be shown to reach the thing it measures.** Three separate probes
had blind spots exactly where the fault was and each produced a confident wrong
conclusion: a panel readback that counted "brighter than 8" against a buffer
that is never cleared, so *nothing drawn* and *drawn black* were the same
number; an `r_flatColor` override that did not apply to multitextured surfaces,
so it only ever covered models; and a flat-red override that restored its state
inside a branch that was never taken, corrupting the renderer it was measuring.
**Verify the change landed.** `r_noDepth` was declared, registered, documented
and tested across several device runs - and never actually used, because the
edit that was supposed to read it silently failed to apply and nothing checked.
Every result attributed to it was a no-op, and it sat in the "known good"
configuration taking credit for what disabling culling was doing. Grep for the
symbol after editing, or read the warning log; a cvar that reads nothing looks
exactly like a cvar that changes nothing.
**Look at the frame.** `vr_captureEye` writes the eye buffer to
`main/vrshotN.tga` every two seconds. One image settled questions that six
rounds of verbal description could not. Pixel counts bound the answer; a picture
gives it.
+132
View File
@@ -0,0 +1,132 @@
# A muzzle flash makes the ground darker
**Status: unsolved, parked, worked around.** `r_worldDlights 0` in
`misc/android/autoexec.cfg` turns the world's dynamic lighting off on the
device. Flashes no longer light the scenery, which is a smaller loss than the
ground going black every time a shot is fired.
This is the record of what was measured, so that picking it up again does not
start from nothing. Seven theories were tried and all seven were wrong. Every
one came from reading the renderer; every useful fact came from the device.
---
## 1. The symptom, measured
Firing in `m4l1` makes a large area of ground **darker**, not black, in lockstep
with the trigger. From a 30 fps capture, the fraction of the frame darker than
luminance 12:
```
206: 0.183 │ 207: 0.035 208: 0.035 209: 0.035
210: 0.181 │ 211: 0.175 212: 0.173
213: 0.036 │ 214: 0.035 215: 0.036
216: 0.179 │ 217: 0.172 218: 0.173
219: 0.037 │ 220: 0.037 221: 0.037
```
Three frames on, three off, at the firing cadence. Differencing frame 216
against 215:
- **336,359 pixels — 16.2% of the frame** go from mean luminance 25.1 to 6.2
- that is a **4.06× multiply**, and the pixels keep their texture (std 2–8,
max 68), so it is a modulate and not a paint-over
- the mask is the **ground plane**: the lower half of the frame with hard
straight polygon edges following the road and the hedgerow silhouette
"Renders black" in the original report is a surface being multiplied down, not
geometry being covered. Chasing a paint-over cost four rounds.
## 2. What is ruled out, and by what
Everything below was measured on the device, not reasoned about.
| Claim | How it died |
|---|---|
| `R_UploadDlights` early-returns on a cleared `tr.pc.c_dlightSurfaces`, so the atlas is never sent | The trace prints `surfaces 36–46` *at the point of upload*. The guard was passing. |
| `glTexSubImage2D` sends `GL_RGBA` into a `GL_RGB` texture — illegal on ES | `err 0x0`. gl4es accepts it. `internal 0x1907` confirms the format really is `GL_RGB`. |
| Terrain is missing the world-origin term in its dynamic texcoords | It is there, `tr_light.c:650-652`. And terrain is **never dlit here** — zero `dlight: terrain` lines in any run. |
| Surfaces shift their texcoords into the atlas frame without the atlas being bound | Zero batches reported it. Every batch that shifted, bound. |
| Faces address atlas rows past the uploaded height | All 24 sampled faces land exactly on their own block, inside the uploaded rows. Worked example below. |
| Quake 3's `ProjectDlightTexture` second pass is drawing black | It **never runs**. Zero lines. |
| The dynamic patch and the static lightmap use different brightness scales | `R_ColorShiftLightingBytes` (`tr_bsp.c:132`) is `<< overbrightShift` then normalise-by-max. `R_DlightSample`'s tail is the same two operations, on the same bytes — `lmData` points into `s_worldData.lighting`, the memcpy of the lump `R_LoadLightmaps` built the static lightmaps from. For an unlit texel the two are identical by construction. |
Worked example of the texcoord check, from `vr_traceDlight 1`:
```
face 1 rows 4.6..6.6 cols 109.7..111.7 offset 0.4062,-0.0234 lm 4x4 at 57,7
```
`offset = x/128 - lmX/128`, so `0.4062 = x/128 - 57/128` gives `x = 109`, and the
face covers cols 109.7–111.7. Same for t: `y = 4`, block 4 tall, rows 4.6–6.6.
Exact. Face 12 (`22x16 at 49,60`, offset `-0.3828,-0.3750`) gives `x=0, y=12`,
covering rows 13–27 of an atlas 28 rows tall. Also exact.
Also checked and not the cause:
- **SMP.** `smpFrame` is declared in both `trGlobals_t` and `backEndState_t` and
**never assigned anywhere**. Front end and back end are always index 0.
- **`texels` exceeding the uploaded area.** `texels 9275` against `rows 28`
(3584 texels) looks impossible but is not: `R_RealDlightFace` counts
`c_dlightTexels` *before* it knows whether any light landed, then bails at
`if (!added)` without committing to `dli.allocated`, so the block is handed
straight back out to the next surface. Failed surfaces are counted and take no
space. This is not a bug and cost a round.
## 3. The one thing that is known to matter
Turning the world's dynamic lighting off — `r_worldDlights 0`, which makes
`R_RealDlightFace`, `R_RealDlightPatch` and `R_RealDlightTerrain` return 0 so
no surface ever gets an atlas block — **makes the dimming stop**.
So the subsystem is involved, despite every component of it measuring correct.
That is the contradiction to resume on.
## 4. Where to pick it up
The next measurement was half-written when this was parked: print, for a few
dlit faces, the raw source texel, the texel actually written into the atlas, and
what `R_ColorShiftLightingBytes` would produce for that same raw texel. All
three should agree for any texel no light reached. If they do not, that is the
bug and the numbers say where. If they do agree, the dimming is downstream of
the patch contents entirely, and the next suspect is what changes in the *draw*
when `tess.dlightMap` becomes non-zero — a different texture object bound on
TMU1, with whatever env, filter and wrap state that carries with it under
gl4es.
One specific thing never checked: `tr.dlightImages[]` are created by
`R_CreateImageOld` with the name `*lightmapD%i`, and the `isLightmap` test is
`strncmp(name, "*lightmap", 9)` — which those names match, so the
`else if (!strncmp(name, "*lightmapD", 10))` branch that sets
`dynamicallyUpdated` is **dead code**. `dynamicallyUpdated` is passed to
`Upload32` and never read there, so it appears inert, but the classification
also decides `internalFormat` and `TMU`. Worth an hour before anything else.
## 5. The diagnostics, and how to use them
All in `vr_traceDlight`, off by default, registered in `tr_init.c`:
| Level | What it does |
|---|---|
| `1` | One line per atlas upload: page, uploaded rows, surface and texel counts, brightest texel of the patch out of 765, the texture's internal format, the GL error, and the overbright and identity scales. Plus per-face and per-terrain-patch reports of exactly which atlas rows and columns each surface's texcoords cover, against the block it was allocated. |
| `2` | Stops measuring, starts staining: the whole atlas is painted **white** before the front end writes to it, and every texel a light reaches is painted magenta on top. Read off the walls. White can only brighten, so anything reading unwritten atlas is unmissable. |
| `3` | Additionally skips `ProjectDlightTexture`. Measured: it never runs here, so this changes nothing. Kept because proving that took a run. |
Two lessons about the probes themselves, which cost more than the bug so far:
**A stain must not look like the level.** The first stain was dark green, in a
level made of hedgerows. Neither of us could tell it from foliage by eye, and
two rounds were spent on "is that green or is that a bush". White was
unambiguous immediately. The pixels also killed it independently: the dimmed
areas have R, G and B roughly equal, which a modulate against a green-only
stain cannot produce.
**Screen recording destroys the log.** A whole run was lost because the Quest's
recorder floods logcat and evicts everything but the shutdown lines.
`adb logcat -G 32M` helps but does not fix it. Record video **or** read the log,
never both in one run.
**Frame differencing beat six rounds of description.** Pulling the recording,
extracting every frame, and differencing a flash frame against its neighbour
produced the cadence, the 4× ratio and the affected geometry in one step. Do
that first next time, not seventh.
+4
View File
@@ -0,0 +1,4 @@
.gradle/
local.properties
*.apk
*.aab
+78
View File
@@ -0,0 +1,78 @@
plugins {
id 'com.android.application'
}
// The native libraries are built by CMake, not by gradle. See
// docs/markdown/04-coding/03-android.md - build the engine first, then package it here.
def nativeLibs = file(rootProject.file(nativeLibsDir).absolutePath)
def sdlJava = sdlJavaDir?.trim()
if (!sdlJava) {
// The CMake build records the SDL tree it used next to the staged libraries
def fromCMake = file("${nativeLibs}/../android-build.properties")
if (fromCMake.exists()) {
def props = new Properties()
fromCMake.withInputStream { props.load(it) }
sdlJava = props.getProperty('sdlJavaDir')
}
}
if (!sdlJava || !file(sdlJava).exists()) {
throw new GradleException(
"Could not find SDL's Java sources. Build the engine with CMake first, " +
"or pass -PsdlJavaDir=<SDL source>/android-project/app/src/main/java. " +
"They must come from the same SDL the engine was linked against.")
}
android {
namespace 'org.openmoh.openmohaa'
compileSdk 34
// Only so AGP can find the NDK's strip tool; the engine itself is built by
// CMake outside gradle. Without it the libraries ship with full debug info,
// which works but makes for a much larger APK.
def ndkFromEnv = System.getenv('ANDROID_NDK_HOME')
if (ndkFromEnv) {
ndkPath = ndkFromEnv
}
defaultConfig {
applicationId "org.openmoh.openmohaa"
// Quest 3 runs Android 12 (API 32)
minSdk 29
targetSdk 32
versionCode 2
versionName "0.83.0-vr0.2.0"
ndk {
abiFilters 'arm64-v8a'
}
}
sourceSets {
main {
java.srcDirs += sdlJava
jniLibs.srcDirs = [nativeLibs]
}
}
buildTypes {
release {
// The engine is already built optimised; nothing here to shrink
minifyEnabled false
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
// The engine binaries are large and already compressed poorly; leaving
// them uncompressed lets the loader mmap them straight out of the APK.
packagingOptions {
jniLibs {
useLegacyPackaging false
}
}
}
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:installLocation="auto">
<!-- OpenXR uses GLES 3; the default GL1 renderer runs through gl4es. -->
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
<!--
Together with the VR intent category below, this is what makes the
headset launch the app immersively instead of as a flat panel.
-->
<uses-feature
android:name="android.hardware.vr.headtracking"
android:required="true"
android:version="1" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<!-- The Touch controllers arrive as a game controller -->
<uses-feature
android:name="android.hardware.gamepad"
android:required="false" />
<uses-feature
android:name="android.hardware.bluetooth"
android:required="false" />
<uses-feature
android:name="android.hardware.usb.host"
android:required="false" />
<!-- Multiplayer, and the master server browser -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!--
Game data lives in the app's own external files directory, which needs
no permission. The user side-loads main/pak*.pk3 into it; no assets are
shipped with the app.
-->
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:hardwareAccelerated="true"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
<activity
android:name=".OpenMoHAAActivity"
android:label="@string/app_name"
android:alwaysRetainTaskState="true"
android:launchMode="singleInstance"
android:configChanges="layoutDirection|locale|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:preferMinimalPostProcessing="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="com.oculus.intent.category.VR" />
</intent-filter>
</activity>
</application>
</manifest>
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more