|
| 1 | +# First-Person Hand Polish |
| 2 | + |
| 3 | +## Summary |
| 4 | + |
| 5 | +The current first-person hand looks bad: it uses a sand block texture for the |
| 6 | +arm, is too large, sits at an unnatural angle, always renders at maximum |
| 7 | +brightness regardless of the environment, and lacks the proportioned |
| 8 | +Minecraft-style corner placement that makes a viewmodel feel grounded. This plan |
| 9 | +fixes all of that without introducing a full animation framework. |
| 10 | + |
| 11 | +## Problems To Fix |
| 12 | + |
| 13 | +### Wrong texture on the arm |
| 14 | + |
| 15 | +The arm/hand cuboid samples the sand block texture (`BLOCK_IDS.sand`). It needs |
| 16 | +a dedicated skin-tone texture tile added to the voxel atlas so the arm looks |
| 17 | +like a player arm instead of a sand block. |
| 18 | + |
| 19 | +### Bad proportions and placement |
| 20 | + |
| 21 | +The arm is too wide and too centred. A Minecraft-style hand sits in the |
| 22 | +lower-right corner at a visible tilt so it reads clearly as an arm receding into |
| 23 | +the screen. Current constants put it roughly centred with minimal inward tilt, |
| 24 | +which is why it looks pasted on. The size and offset constants in |
| 25 | +`player-model.ts` need to be tuned to: |
| 26 | + |
| 27 | +- Thinner cross-section (roughly 0.14 × 0.14 wide/deep, 0.60 tall) |
| 28 | +- Pushed further right and down so only the lower-right corner is visible |
| 29 | +- Tilted inward (positive roll on the arm mesh so the face angle points slightly |
| 30 | + toward the camera) to give the receding-perspective feel |
| 31 | + |
| 32 | +### No separate viewmodel projection |
| 33 | + |
| 34 | +The hand currently renders in the same world-space projection as terrain, with |
| 35 | +depth test disabled as a workaround. This causes it to scale with world FOV, |
| 36 | +making it look enormous at wider FOV settings and causing the size to jump when |
| 37 | +FOV is changed in settings. The fix is to render the viewmodel with a fixed |
| 38 | +narrower FOV (e.g. 70°) independent of the world FOV, the standard approach for |
| 39 | +FPS viewmodels. This requires building a second projection matrix during the |
| 40 | +viewmodel pass. |
| 41 | + |
| 42 | +### Flat max-brightness lighting |
| 43 | + |
| 44 | +The hand always draws at full brightness because the sky/block light values |
| 45 | +passed to the voxel shader are hardcoded to max. It should sample the light level |
| 46 | +at the player's current block position so the hand dims appropriately in caves, |
| 47 | +under overhangs, and at night. |
| 48 | + |
| 49 | +### Held item floats disconnected from the hand |
| 50 | + |
| 51 | +The held item uses separate position and rotation offsets that don't visually |
| 52 | +attach it to the hand. The offsets need to be tuned so the item appears to be |
| 53 | +gripped at the top of the visible arm segment rather than floating nearby. |
| 54 | + |
| 55 | +### No movement bob |
| 56 | + |
| 57 | +There is a swing animation but no idle or movement bob. A subtle vertical bob |
| 58 | +tied to horizontal movement speed makes the hand feel alive and grounded. |
| 59 | + |
| 60 | +## Key Changes |
| 61 | + |
| 62 | +### Add an arm skin tile to the atlas |
| 63 | + |
| 64 | +- Add a new `arm` tile to the source tile set (a simple skin-tone flat colour, or |
| 65 | + a small hand/arm texture consistent with the blocky art style). |
| 66 | +- Register it in the content registry as a non-placeable internal block ID |
| 67 | + (`PLAYER_ARM_BLOCK_ID`), replacing the current sand fallback. |
| 68 | +- Re-run atlas generation (`bun run build:native` or the atlas preprocessing |
| 69 | + step) so the tile appears in `voxel-atlas.png`. |
| 70 | + |
| 71 | +### Tune proportions and corner placement |
| 72 | + |
| 73 | +In `player-model.ts`: |
| 74 | + |
| 75 | +- Reduce `FIRST_PERSON_ARM_PART.size` to approximately `[0.14, 0.60, 0.14]`. |
| 76 | +- Adjust the position offsets in `player-renderer.ts` so the arm is anchored in |
| 77 | + the lower-right corner: increase the right offset, increase the downward |
| 78 | + offset, and pull slightly closer (reduce the forward offset). |
| 79 | +- Add a roll component to the arm's model matrix so the arm face tilts inward |
| 80 | + ~25–35°, giving the receding-perspective look. |
| 81 | + |
| 82 | +### Render the viewmodel with a fixed FOV |
| 83 | + |
| 84 | +- In `renderer.ts`, before the viewmodel draw call, compute a secondary |
| 85 | + projection matrix using a fixed FOV (70° recommended) instead of the world |
| 86 | + camera FOV. |
| 87 | +- Pass this alternate projection to the voxel shader uniform for the viewmodel |
| 88 | + pass only, then restore the world projection for the next frame. |
| 89 | +- Keep near/far planes tight for the viewmodel (near ~0.01, far ~5.0) to avoid |
| 90 | + z-fighting with nearby geometry. |
| 91 | + |
| 92 | +### Sample environment lighting at the player position |
| 93 | + |
| 94 | +- In `player-renderer.ts`, read the sky light and block light at the player's |
| 95 | + foot or eye block from `ClientWorldRuntime` (the same chunk/lighting data |
| 96 | + already used for chunk meshing). |
| 97 | +- Pass those values as the `aSkyLight` and `aBlockLight` vertex attributes when |
| 98 | + building the arm and held-item mesh each frame, replacing the current hardcoded |
| 99 | + max values. |
| 100 | +- The existing daylight-aware brightness formula in the fragment shader then |
| 101 | + handles dimming automatically. |
| 102 | + |
| 103 | +### Attach the held item to the arm |
| 104 | + |
| 105 | +- Revise the held-item position and rotation offsets in `player-renderer.ts` so |
| 106 | + the item renders at the top of the visible arm, as if gripped. |
| 107 | +- A good reference: held item should sit slightly left of arm centre, at arm-top |
| 108 | + height, and share nearly the same yaw/pitch tilt as the arm rather than having |
| 109 | + an independent rotation. |
| 110 | + |
| 111 | +### Add a movement bob |
| 112 | + |
| 113 | +- In `player-renderer.ts`, track horizontal movement speed each frame (derive |
| 114 | + from the local player's velocity or position delta). |
| 115 | +- Compute a bob phase that advances while the player is moving: |
| 116 | + `bobPhase += movementSpeed * dt * bobFrequency` |
| 117 | +- Apply a small vertical sine offset to the arm and held-item position: |
| 118 | + `bobOffset = sin(bobPhase) * bobAmplitude` where amplitude is ~0.012. |
| 119 | +- Keep bob amplitude small enough that it is not nauseating. It should be barely |
| 120 | + perceptible but present. |
| 121 | +- Blend bob offset smoothly to zero when the player stops moving. |
| 122 | + |
| 123 | +## Important Files |
| 124 | + |
| 125 | +- `plans/0040-first-person-hand-polish.md` |
| 126 | +- `apps/client/src/render/player-renderer.ts` — position, rotation, lighting |
| 127 | +- `apps/client/src/render/player-model.ts` — geometry size and offset constants |
| 128 | +- `apps/client/src/render/renderer.ts` — viewmodel projection and draw order |
| 129 | +- `apps/client/assets/shaders/voxel.vert` / `voxel.frag` — lighting uniforms |
| 130 | +- `apps/client/assets/textures/tiles/` — source tiles for atlas |
| 131 | +- `packages/core/src/world/content-registry.ts` — arm block ID registration |
| 132 | +- `packages/core/src/world/item-render.ts` — face geometry helpers |
| 133 | + |
| 134 | +## Out Of Scope |
| 135 | + |
| 136 | +- Left-hand rendering |
| 137 | +- Tool-specific animations (mining swing, eating, etc.) |
| 138 | +- Offhand slot |
| 139 | +- Player skin customisation |
| 140 | +- Shadow casting from the viewmodel |
| 141 | + |
| 142 | +## Test Plan |
| 143 | + |
| 144 | +- Manual smoke tests: |
| 145 | + - Hand is visibly skin-toned and no longer looks like a sand block |
| 146 | + - Hand sits in the lower-right corner at a natural inward tilt |
| 147 | + - Changing FOV in settings does not change the apparent hand size |
| 148 | + - Walking causes a subtle vertical bob; stopping smoothly ends the bob |
| 149 | + - Entering a dark cave causes the hand to dim with the environment |
| 150 | + - Night cycle dims the hand consistently with the world lighting |
| 151 | + - Held item visually attaches to the top of the arm, not floating nearby |
| 152 | + - Swing animation still plays correctly on break/place |
| 153 | +- Regression: |
| 154 | + - Remote player arm appearance unchanged (shares the same arm block ID but |
| 155 | + renders through the world-space player pass, not the viewmodel pass) |
| 156 | + - FOV slider still affects world geometry FOV as expected |
| 157 | + - No z-fighting between hand and near-clipped world geometry |
0 commit comments