Compare commits

...

16 commits

Author SHA1 Message Date
Kelsi
f374e19239 Comprehensive README/status update covering 60+ commits since Feb 2026
Some checks are pending
Build / Build (arm64) (push) Waiting to run
Build / Build (x86-64) (push) Waiting to run
Build / Build (macOS arm64) (push) Waiting to run
Build / Build (windows-arm64) (push) Waiting to run
Build / Build (windows-x86-64) (push) Waiting to run
Security / CodeQL (C/C++) (push) Waiting to run
Security / Semgrep (push) Waiting to run
Security / Sanitizer Build (ASan/UBSan) (push) Waiting to run
Add instances, bank, auction house, mail, pets, party stats, map
exploration, talent/spellbook revamp, chest looting, spirit healer,
CI builds, performance optimizations, shadow/collision/lava fixes.
2026-03-07 00:55:34 -08:00
Kelsi
88dc1f94a6 Update README and status docs for v0.3.6-preview
- Add water/lava/lighting rendering features to README
- Add transport riding to movement features
- Update status date and rendering capabilities
- Note interior shadow and lava steam known gaps
2026-03-07 00:50:45 -08:00
Kelsi
41218a3b04 Remove diagnostic logging for lava/steam/MLIQ 2026-03-07 00:49:11 -08:00
Kelsi
a24fe4cc45 Ironforge Great Forge lava, magma water rendering, LavaSteam particle effects
- Add magma/slime rendering path to water shader (fbm noise, crust/molten/core coloring)
- Fix WMO liquid height filter rejecting high-altitude zones like Ironforge (Z>300)
- Allow interior WMO magma/slime MLIQ groups to load (skip only water/ocean)
- Mark LAVASTEAM.m2 as spell effect for proper additive blend, hide emission mesh
- Add isLavaModel flag for M2 ForgeLava/LavaPots UV scroll fallback
- Add isLava material detection in WMO renderer for lava texture UV animation
- Fix WMO material UBO colors for magma (was blue, now orange-red)
2026-03-07 00:48:04 -08:00
Kelsi
2c5b7cd368 WMO glass transparency for instances, disable interior shadows
- Add case-insensitive "glass" detection for WMO window materials
- Make instance (WMO-only) glass highly transparent (12-35% alpha)
  so underwater scenes are visible through Deeprun Tram windows
- Keep normal world windows at existing opacity (40-95% alpha)
- Disable shadow mapping for interior WMO groups to fix dark
  indoor areas like Ironforge
2026-03-06 23:48:35 -08:00
Kelsi
f4c115ade9 Fix Deeprun Tram: visual movement, direction, and player riding
- Fix NULL renderer pointers by moving TransportManager connection after
  initializeRenderers for WMO-only maps
- Fix tram direction by negating DBC TransportAnimation X/Y local offsets
  before serverToCanonical conversion
- Implement client-side M2 transport boarding via proximity detection
  (server doesn't send transport attachment for trams)
- Use position-delta approach: player keeps normal movement while
  transport's frame-to-frame motion is applied on top
- Prevent server movement packets from clearing client-side M2 transport
  state (isClientM2Transport guard)
- Fix getPlayerWorldPosition for M2 transports: simple canonical addition
  instead of render-space matrix multiplication
2026-03-06 23:01:11 -08:00
Kelsi
e001aaa2b6 Suppress movement after teleport/portal, add shadow distance slider
- Add movementSuppressTimer to camera controller that forces all movement
  keys to read as false, preventing held W key from carrying through
  loading screens (fixes always-running-forward after instance portals)
- Increase shadow frustum default from 60 to 72 units (+20%)
- Make shadow distance configurable via setShadowDistance() (40-200 range)
- Add shadow distance slider in Video settings tab (persisted to config)
2026-03-06 20:38:58 -08:00
Kelsi
e4d94e5d7c Fix terrain water horizontal flip by correcting step vector axes
stepX/stepY were transposed: columns stepped south instead of east and
rows stepped east instead of south, mirroring all overworld water. Also
fix per-chunk origin to use layer.x for east offset and layer.y for south.
2026-03-06 20:23:22 -08:00
Kelsi
ad66ef9ca6 Fix shadow flicker: render every frame, tighten shadow frustum
Remove frame throttling that skipped shadow updates in dense scenes,
causing visible flicker on player and NPCs. Reduce shadow half-extent
from 180 to 60 for 3x higher resolution on nearby shadows.
2026-03-06 20:04:19 -08:00
Kelsi
8014dde29b Improve WMO wall collision, unstuck, interior zoom, and chat focus
- Stronger wall collision push (0.35/0.15) and swept push (0.45/0.25)
  for interior/exterior WMOs to reduce clipping through tunnel walls
- Use all triangles (not just pre-classified walls) for collision checks
- Allow invisible collidable triangles (MOPY 0x01 without 0x20) to block
- Pass insideWMO flag to all collision callers, match swim sweep to ground
- Widen swept hit detection radius from 0.15 to 0.25
- Restrict camera zoom to 12 units inside WMO interiors
- Fix /unstuck launching player above WMOs: remove +20 fallback, use
  gravity when no floor found
- Slash and Enter keys always focus chat unless already typing
2026-03-06 20:00:27 -08:00
Kelsi
4cae4bfcdc Fix WMO shadow culling: use AABB instead of origin point distance
WMO origins can be far from their visible geometry, causing large city
buildings to be culled from the shadow pass. Use world bounding box for
instance culling and per-group AABB culling. Also increase WMO shadow
cull radius to match the shadow map coverage (180 units).
2026-03-06 19:21:48 -08:00
Kelsi
5a227c0376 Add water refraction toggle with per-frame scene history
Fix VK_ERROR_DEVICE_LOST crash by allocating per-frame scene history
images (color + depth) instead of a single shared image that raced
between frames in flight. Water refraction can now be toggled via
Settings > Video > Water Refraction.

Without refraction: richer blue base colors, animated caustic shimmer,
and normal-based color shifts give the water visible life. With
refraction: clean screen-space refraction with Beer-Lambert absorption.
Disabling clears scene history to black for immediate fallback.
2026-03-06 19:15:34 -08:00
Kelsi
7630c7aec7 Fix WMO doodad rotation: remove incorrect quaternion X/Y swap
The glm::quat(w,x,y,z) constructor was receiving swapped X/Y components,
causing doodads like the Deeprun Tram gears to be oriented horizontally
instead of vertically. Also use createInstanceWithMatrix for instance WMO
doodads to preserve full rotation from the quaternion.
2026-03-06 18:48:12 -08:00
Kelsi
8b0c2a0fa1 Fix minimap horizontal inversion by flipping composite sampling 2026-03-06 18:38:38 -08:00
Kelsi
585d0bf50e Instance portal glow, spin, and transparent additive rendering 2026-03-06 18:03:08 -08:00
Kelsi
dfc53f30a8 Effect model additive blend, teleport facing, tighter area triggers
Classify light shafts, portals, spotlights, bubbles, and similar M2
doodads as spell effects so they render with additive blending instead
of as solid opaque objects.

Set camera yaw from server orientation on world load so teleports face
the correct direction.

Reduce area trigger minimum radius (3.0 sphere, 4.0 box) to prevent
premature portal firing near tram entrances.
2026-03-06 18:03:08 -08:00
26 changed files with 938 additions and 384 deletions

View file

@ -19,21 +19,26 @@ Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**.
> **Legal Disclaimer**: This is an educational/research project. It does not include any Blizzard Entertainment assets, data files, or proprietary code. World of Warcraft and all related assets are the property of Blizzard Entertainment, Inc. This project is not affiliated with or endorsed by Blizzard Entertainment. Users are responsible for supplying their own legally obtained game data files and for ensuring compliance with all applicable laws in their jurisdiction.
## Status & Direction (2026-02-18)
## Status & Direction (2026-03-07)
- **Compatibility**: **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a** are all supported via expansion profiles and per-expansion packet parsers (`src/game/packet_parsers_classic.cpp`, `src/game/packet_parsers_tbc.cpp`). All three expansions are roughly on par — no single one is significantly more complete than the others.
- **Tested against**: AzerothCore, TrinityCore, and Mangos.
- **Current focus**: protocol correctness across server variants, visual accuracy (M2/WMO edge cases, equipment textures), and multi-expansion coverage.
- **Tested against**: AzerothCore, TrinityCore, Mangos, and Turtle WoW (1.17).
- **Current focus**: instance dungeons, visual accuracy (lava/water, shadow mapping, WMO interiors), and multi-expansion coverage.
- **Warden**: Full module execution via Unicorn Engine CPU emulation. Decrypts (RC4→RSA→zlib), parses and relocates the PE module, executes via x86 emulation with Windows API interception. Module cache at `~/.local/share/wowee/warden_cache/`.
- **CI**: GitHub Actions builds for Linux (x86-64, ARM64), Windows (MSYS2), and macOS (ARM64). Security scans via CodeQL, Semgrep, and sanitizers.
## Features
### Rendering Engine
- **Terrain** -- Multi-tile streaming with async loading, texture splatting (4 layers), frustum culling
- **Terrain** -- Multi-tile streaming with async loading, texture splatting (4 layers), frustum culling, expanded load radius during taxi flights
- **Atmosphere** -- Procedural clouds (FBM noise), lens flare with chromatic aberration, cloud/fog star occlusion
- **Characters** -- Skeletal animation with GPU vertex skinning (256 bones), race-aware textures
- **Buildings** -- WMO renderer with multi-material batches, frustum culling, 160-unit distance culling
- **Particles** -- M2 particle emitters with WotLK struct parsing, billboarded glow effects
- **Characters** -- Skeletal animation with GPU vertex skinning (256 bones), race-aware textures, per-instance NPC hair/skin textures
- **Buildings** -- WMO renderer with multi-material batches, frustum culling, collision (wall/floor classification, slope sliding), interior glass transparency
- **Instances** -- WDT parser for WMO-only dungeon maps, area trigger portals with glow/spin effects, seamless zone transitions
- **Water & Lava** -- Terrain and WMO water with refraction/reflection, magma/slime rendering with multi-octave FBM noise flow, Beer-Lambert absorption, M2 lava waterfalls with UV scroll
- **Particles** -- M2 particle emitters with WotLK struct parsing, billboarded glow effects, lava steam/splash effects
- **Lighting** -- Shadow mapping with PCF filtering, per-frame shadow updates, AABB-based culling, interior/exterior light modes, WMO window glass with fresnel reflections
- **Performance** -- Binary keyframe search for animations, incremental spatial index, static doodad skip, hash-free render/shadow culling
### Asset Pipeline
- Extracted loose-file **`Data/`** tree indexed by **`manifest.json`** (fast lookup + caching)
@ -44,21 +49,27 @@ Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**.
### Gameplay Systems
- **Authentication** -- Full SRP6a implementation with RC4 header encryption
- **Character System** -- Creation (with nonbinary gender option), selection, 3D preview, stats panel, race/class support
- **Movement** -- WASD movement, camera orbit, spline path following
- **Combat** -- Auto-attack, spell casting with cooldowns, damage calculation, death handling
- **Targeting** -- Tab-cycling, click-to-target, faction-based hostility (using Faction.dbc)
- **Inventory** -- 23 equipment slots, 16 backpack slots, drag-drop, auto-equip
- **Spells** -- Spellbook with class specialty tabs, drag-drop to action bar, spell icons
- **Movement** -- WASD movement, camera orbit, spline path following, transport riding (trams, ships, zeppelins), movement ACK responses
- **Combat** -- Auto-attack, spell casting with cooldowns, damage calculation, death handling, spirit healer resurrection
- **Targeting** -- Tab-cycling with hostility filtering, click-to-target, faction-based hostility (using Faction.dbc)
- **Inventory** -- 23 equipment slots, 16 backpack slots, drag-drop, auto-equip, item tooltips with weapon damage/speed
- **Bank** -- Full bank support for all expansions, bag slots, drag-drop, right-click deposit (non-equippable items)
- **Spells** -- Spellbook with specialty, general, profession, mount, and companion tabs; drag-drop to action bar; item use support
- **Talents** -- Talent tree UI with proper visuals and functionality
- **Action Bar** -- 12 slots, drag-drop from spellbook/inventory, click-to-cast, keybindings
- **Trainers** -- Spell trainer UI, buy spells, known/available/unavailable states
- **Quests** -- Quest markers (! and ?) on NPCs and minimap, quest log, quest details, turn-in flow
- **Quests** -- Quest markers (! and ?) on NPCs and minimap, quest log, quest details, turn-in flow, quest item progress tracking
- **Auction House** -- Search with filters, pagination, sell picker with tooltips, bid/buyout
- **Mail** -- Item attachment support for sending
- **Vendors** -- Buy, sell, and buyback (most recent sold item), gold tracking, inventory sync
- **Loot** -- Loot window, gold looting, item pickup
- **Loot** -- Loot window, gold looting, item pickup, chest/gameobject looting
- **Gossip** -- NPC interaction, dialogue options
- **Chat** -- Tabs/channels, emotes, chat bubbles, clickable URLs, clickable item links with tooltips
- **Party** -- Group invites, party list
- **Party** -- Group invites, party list, out-of-range member health via SMSG_PARTY_MEMBER_STATS
- **Pets** -- Pet tracking via SMSG_PET_SPELLS, dismiss pet button
- **Map Exploration** -- Subzone-level fog-of-war reveal matching retail behavior
- **Warden** -- Warden anti-cheat module execution via Unicorn Engine x86 emulation (cross-platform, no Wine)
- **UI** -- Loading screens with progress bar, settings window, minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view)
- **UI** -- Loading screens with progress bar, settings window (shadow distance slider), minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view)
## Building
@ -214,6 +225,11 @@ make -j$(nproc)
- [Warden Quick Reference](docs/WARDEN_QUICK_REFERENCE.md) -- Warden module execution overview and testing
- [Warden Implementation](docs/WARDEN_IMPLEMENTATION.md) -- Technical details of the implementation
## CI / CD
- GitHub Actions builds on every push: Linux (x86-64, ARM64), Windows (MSYS2), macOS (ARM64)
- Container build via `container/build-in-container.sh` (Podman)
## Security
- GitHub Actions runs a dedicated security workflow at `.github/workflows/security.yml`.

View file

@ -40,7 +40,7 @@ void main() {
float cs = cos(push.rotation);
float sn = sin(push.rotation);
vec2 rotated = vec2(center.x * cs - center.y * sn, center.x * sn + center.y * cs);
vec2 mapUV = push.playerUV + rotated * push.zoomRadius * 2.0;
vec2 mapUV = push.playerUV + vec2(-rotated.x, rotated.y) * push.zoomRadius * 2.0;
vec4 mapColor = texture(uComposite, mapUV);
@ -48,7 +48,7 @@ void main() {
float acs = cos(push.arrowRotation);
float asn = sin(push.arrowRotation);
vec2 ac = center;
vec2 arrowPos = vec2(ac.x * acs - ac.y * asn, ac.x * asn + ac.y * acs);
vec2 arrowPos = vec2(-(ac.x * acs - ac.y * asn), ac.x * asn + ac.y * acs);
vec2 tip = vec2(0.0, -0.04);
vec2 left = vec2(-0.02, 0.02);

View file

@ -155,6 +155,52 @@ void main() {
float time = fogParams.z;
float basicType = push.liquidBasicType;
// ============================================================
// Magma / Slime — self-luminous flowing surfaces, skip water path
// ============================================================
if (basicType > 1.5) {
float dist = length(viewPos.xyz - FragPos);
vec2 flowUV = FragPos.xy;
bool isMagma = basicType < 2.5;
// Multi-octave flowing noise for organic lava look
float n1 = fbmNoise(flowUV * 0.06 + vec2(time * 0.02, time * 0.03), time * 0.4);
float n2 = fbmNoise(flowUV * 0.10 + vec2(-time * 0.015, time * 0.025), time * 0.3);
float n3 = noiseValue(flowUV * 0.25 + vec2(time * 0.04, -time * 0.02));
float flow = n1 * 0.45 + n2 * 0.35 + n3 * 0.20;
// Dark crust vs bright molten core
vec3 crustColor, hotColor, coreColor;
if (isMagma) {
crustColor = vec3(0.15, 0.04, 0.01); // dark cooled rock
hotColor = vec3(1.0, 0.45, 0.05); // orange molten
coreColor = vec3(1.0, 0.85, 0.3); // bright yellow-white core
} else {
crustColor = vec3(0.05, 0.15, 0.02);
hotColor = vec3(0.3, 0.8, 0.15);
coreColor = vec3(0.5, 1.0, 0.3);
}
// Three-tier color: crust → molten → hot core
float crustMask = smoothstep(0.25, 0.50, flow);
float coreMask = smoothstep(0.60, 0.80, flow);
vec3 color = mix(crustColor, hotColor, crustMask);
color = mix(color, coreColor, coreMask);
// Subtle pulsing emissive glow
float pulse = 1.0 + 0.15 * sin(time * 1.5 + flow * 6.0);
color *= pulse;
// Emissive brightening for hot areas
color *= 1.0 + coreMask * 0.6;
float fogFactor = clamp((fogParams.y - dist) / (fogParams.y - fogParams.x), 0.0, 1.0);
color = mix(fogColor.rgb, color, fogFactor);
outColor = vec4(color, 0.97);
return;
}
vec2 screenUV = gl_FragCoord.xy / vec2(textureSize(SceneColor, 0));
// --- Normal computation ---
@ -226,11 +272,32 @@ void main() {
float depthFade = 1.0 - exp(-verticalDepth * 0.15);
vec3 waterBody = mix(shallowColor, deepColor, depthFade);
vec3 refractedColor = mix(foggedScene * absorbed, waterBody, depthFade * 0.7);
// Detect if scene history is available (scene data captured for refraction)
float sceneBrightness = dot(sceneRefract, vec3(0.299, 0.587, 0.114));
bool hasSceneData = (sceneBrightness > 0.003);
if (verticalDepth < 0.01) {
float opticalDepth = 1.0 - exp(-dist * 0.004);
refractedColor = mix(foggedScene, waterBody, opticalDepth * 0.6);
// Animated caustic shimmer — only without refraction (refraction already provides movement)
if (!hasSceneData) {
float caustic1 = noiseValue(FragPos.xy * 1.8 + time * vec2(0.3, 0.15));
float caustic2 = noiseValue(FragPos.xy * 3.2 - time * vec2(0.2, 0.35));
float causticPattern = caustic1 * 0.6 + caustic2 * 0.4;
vec3 causticTint = vec3(0.08, 0.18, 0.28) * smoothstep(0.35, 0.75, causticPattern);
waterBody += causticTint;
}
vec3 refractedColor;
if (hasSceneData) {
refractedColor = mix(foggedScene * absorbed, waterBody, depthFade * 0.7);
if (verticalDepth < 0.01) {
float opticalDepth = 1.0 - exp(-dist * 0.004);
refractedColor = mix(foggedScene, waterBody, opticalDepth * 0.6);
}
} else {
// No refraction data — use lit water body with animated variation
vec3 litWater = waterBody * (ambientColor.rgb * 0.8 + NdotL * lightColor.rgb * 0.6);
float normalShift = dot(detailNorm.xy, vec2(0.5, 0.5));
litWater += vec3(0.02, 0.06, 0.10) * normalShift;
refractedColor = litWater;
}
vec3 litBase = waterBody * (ambientColor.rgb * 0.7 + NdotL * lightColor.rgb * 0.5);

Binary file not shown.

View file

@ -28,6 +28,7 @@ layout(set = 1, binding = 1) uniform WMOMaterial {
int pomMaxSamples;
float heightMapVariance;
float normalMapStrength;
int isLava;
};
layout(set = 1, binding = 2) uniform sampler2D uNormalHeightMap;
@ -120,6 +121,14 @@ void main() {
// Compute final UV (with POM if enabled)
vec2 finalUV = TexCoord;
// Lava/magma: scroll UVs for flowing effect
if (isLava != 0) {
float time = fogParams.z;
// Scroll both axes — pools get horizontal flow, waterfalls get vertical flow
// (UV orientation depends on mesh, so animate both)
finalUV += vec2(time * 0.04, time * 0.06);
}
// Build TBN matrix
vec3 T = normalize(Tangent);
vec3 B = normalize(Bitangent);
@ -152,9 +161,9 @@ void main() {
vec3 result;
// Sample shadow map for all non-window WMO surfaces
// Sample shadow map — skip for interior WMO groups (no sun indoors)
float shadow = 1.0;
if (shadowParams.x > 0.5) {
if (shadowParams.x > 0.5 && isInterior == 0) {
vec3 ldir = normalize(-lightDir.xyz);
float normalOffset = SHADOW_TEXEL * 2.0 * (1.0 - abs(dot(norm, ldir)));
vec3 biasedPos = FragPos + norm * normalOffset;
@ -170,7 +179,10 @@ void main() {
shadow = mix(1.0, shadow, shadowParams.y);
}
if (unlit != 0) {
if (isLava != 0) {
// Lava is self-luminous — bright emissive, no shadows
result = texColor.rgb * 1.5;
} else if (unlit != 0) {
result = texColor.rgb * shadow;
} else if (isInterior != 0) {
vec3 mocv = max(VertColor.rgb, vec3(0.5));
@ -219,7 +231,12 @@ void main() {
glass += specBroad * lightColor.rgb * 0.12;
result = glass;
alpha = mix(0.4, 0.95, NdotV);
if (isWindow == 2) {
// Instance/dungeon glass: mostly transparent to see through
alpha = mix(0.12, 0.35, fresnel);
} else {
alpha = mix(0.4, 0.95, NdotV);
}
}
outColor = vec4(result, alpha);

Binary file not shown.

View file

@ -1,6 +1,6 @@
# Project Status
**Last updated**: 2026-02-19
**Last updated**: 2026-03-07
## What This Repo Is
@ -11,23 +11,34 @@ Wowee is a native C++ World of Warcraft client experiment focused on connecting
Implemented (working in normal use):
- Auth flow: SRP6a auth + realm list + world connect with header encryption
- Rendering: terrain, WMO/M2 rendering, water, sky system, particles, minimap/world map, loading video playback
- Character system: creation (including nonbinary gender), selection, 3D preview with equipment, character screen
- Core gameplay: movement, targeting, combat, action bar, inventory/equipment, chat (tabs/channels, emotes, item links)
- Quests: quest markers (! and ?) on NPCs/minimap, quest log with detail queries/retry, objective tracking, accept/complete flow, turn-in
- Rendering: terrain, WMO/M2, water/magma/slime (FBM noise shaders), sky system, particles, shadow mapping, minimap/world map, loading video playback
- Instances: WDT parser, WMO-only dungeon maps, area trigger portals with glow/spin effects, zone transitions
- Character system: creation (including nonbinary gender), selection, 3D preview with equipment, character screen, per-instance NPC hair/skin textures
- Core gameplay: movement (with ACK responses), targeting (hostility-filtered tab-cycle), combat, action bar, inventory/equipment, chat (tabs/channels, emotes, item links)
- Quests: quest markers (! and ?) on NPCs/minimap, quest log with detail queries/retry, objective tracking, accept/complete flow, turn-in, quest item progress
- Trainers: spell trainer UI, buy spells, known/available/unavailable states
- Vendors, loot, gossip dialogs (including buyback for most recently sold item)
- Spellbook with class tabs, drag-drop to action bar, spell icons
- Vendors, loot (including chest/gameobject loot), gossip dialogs (including buyback for most recently sold item)
- Bank: full bank support for all expansions, bag slots, drag-drop, right-click deposit
- Auction house: search with filters, pagination, sell picker, bid/buyout, tooltips
- Mail: item attachment support for sending
- Spellbook with specialty/general/profession/mount/companion tabs, drag-drop to action bar, spell icons, item use
- Talent tree UI with proper visuals and functionality
- Pet tracking (SMSG_PET_SPELLS), dismiss pet button
- Party: group invites, party list, out-of-range member health (SMSG_PARTY_MEMBER_STATS)
- Map exploration: subzone-level fog-of-war reveal
- Warden anti-cheat: full module execution via Unicorn Engine x86 emulation; module caching
- Audio: ambient, movement, combat, spell, and UI sound systems
- Bag UI: separate bag windows, open-bag indicator on bag bar, optional collapse-empty mode in aggregate bag view
- Multi-expansion: Classic/Vanilla, TBC, WotLK, and Turtle WoW (1.17) protocol and asset variants
- CI: GitHub Actions for Linux (x86-64, ARM64), Windows (MSYS2), macOS (ARM64); container builds via Podman
In progress / known gaps:
- Transports (ships, zeppelins, elevators): partial support, timing and edge cases still buggy
- Transports: M2 transports (trams) working with position-delta riding; WMO transports (ships, zeppelins) working with path following; some edge cases remain
- 3D positional audio: not implemented (mono/stereo only)
- Visual edge cases: some M2/WMO rendering gaps (character shin mesh, some particle effects)
- Interior rendering: WMO interior shadows disabled (too dark); lava steam particles sparse
- Water refraction: implemented but disabled by default (can cause VK_ERROR_DEVICE_LOST on some GPUs)
## Where To Look

View file

@ -658,6 +658,9 @@ public:
playerTransportStickyTimer_ = 8.0f;
movementInfo.transportGuid = transportGuid;
}
void setPlayerTransportOffset(const glm::vec3& offset) {
playerTransportOffset_ = offset;
}
void clearPlayerTransport() {
if (playerTransportGuid_ != 0) {
playerTransportStickyGuid_ = playerTransportGuid_;

View file

@ -9,6 +9,7 @@
namespace wowee::rendering {
class WMORenderer;
class M2Renderer;
}
namespace wowee::pipeline {
@ -71,6 +72,7 @@ struct ActiveTransport {
float serverAngularVelocity;
bool hasServerVelocity;
bool allowBootstrapVelocity; // Disable DBC bootstrap when spawn/path mismatch is clearly invalid
bool isM2 = false; // True if rendered as M2 (not WMO), uses M2Renderer for transforms
};
class TransportManager {
@ -79,12 +81,14 @@ public:
~TransportManager();
void setWMORenderer(rendering::WMORenderer* renderer) { wmoRenderer_ = renderer; }
void setM2Renderer(rendering::M2Renderer* renderer) { m2Renderer_ = renderer; }
void update(float deltaTime);
void registerTransport(uint64_t guid, uint32_t wmoInstanceId, uint32_t pathId, const glm::vec3& spawnWorldPos, uint32_t entry = 0);
void unregisterTransport(uint64_t guid);
ActiveTransport* getTransport(uint64_t guid);
const std::unordered_map<uint64_t, ActiveTransport>& getTransports() const { return transports_; }
glm::vec3 getPlayerWorldPosition(uint64_t transportGuid, const glm::vec3& localOffset);
glm::mat4 getTransportInvTransform(uint64_t transportGuid);
@ -141,6 +145,7 @@ private:
std::unordered_map<uint32_t, TransportPath> paths_; // Indexed by transportEntry (pathId from TransportAnimation.dbc)
std::unordered_map<uint32_t, TransportPath> taxiPaths_; // Indexed by TaxiPath.dbc ID (world-coord paths for MO_TRANSPORT)
rendering::WMORenderer* wmoRenderer_ = nullptr;
rendering::M2Renderer* m2Renderer_ = nullptr;
bool clientSideAnimation_ = false; // DISABLED - use server positions instead of client prediction
float elapsedTime_ = 0.0f; // Total elapsed time (seconds)
};

View file

@ -81,6 +81,7 @@ public:
bool isSitting() const { return sitting; }
bool isSwimming() const { return swimming; }
bool isInsideWMO() const { return cachedInsideWMO; }
void setGrounded(bool g) { grounded = g; }
bool isOnTaxi() const { return externalFollow_; }
const glm::vec3* getFollowTarget() const { return followTarget; }
glm::vec3* getFollowTargetMutable() { return followTarget; }
@ -96,6 +97,7 @@ public:
void setExternalMoving(bool moving) { externalMoving_ = moving; }
void setFacingYaw(float yaw) { facingYaw = yaw; } // For taxi/scripted movement
void clearMovementInputs();
void suppressMovementFor(float seconds) { movementSuppressTimer_ = seconds; }
// Trigger mount jump (applies vertical velocity for physics hop)
void triggerMountJump();
@ -141,6 +143,7 @@ private:
static constexpr float MIN_DISTANCE = 0.5f; // Minimum zoom (first-person threshold)
static constexpr float MAX_DISTANCE_NORMAL = 22.0f; // Default max zoom out
static constexpr float MAX_DISTANCE_EXTENDED = 50.0f; // Extended max zoom out
static constexpr float MAX_DISTANCE_INTERIOR = 12.0f; // Max zoom inside WMOs
bool extendedZoom_ = false;
static constexpr float ZOOM_SMOOTH_SPEED = 15.0f; // How fast zoom eases
static constexpr float CAM_SMOOTH_SPEED = 20.0f; // How fast camera position smooths
@ -209,6 +212,9 @@ private:
static constexpr float SWIM_SINK_SPEED = -3.0f;
static constexpr float WATER_SURFACE_OFFSET = 0.9f;
// Movement input suppression (after teleport/portal, ignore held keys)
float movementSuppressTimer_ = 0.0f;
// State
bool enabled = true;
bool sitting = false;

View file

@ -112,12 +112,14 @@ struct M2ModelGPU {
bool hasAnimation = false; // True if any bone has keyframes
bool isSmoke = false; // True for smoke models (UV scroll animation)
bool isSpellEffect = false; // True for spell effect models (skip particle dampeners)
bool isInstancePortal = false; // Instance portal model (spin + glow)
bool disableAnimation = false; // Keep foliage/tree doodads visually stable
bool shadowWindFoliage = false; // Apply wind sway in shadow pass for foliage/tree cards
bool isFoliageLike = false; // Model name matches foliage/tree/bush/grass etc (precomputed)
bool isElvenLike = false; // Model name matches elf/elven/quel (precomputed)
bool isLanternLike = false; // Model name matches lantern/lamp/light (precomputed)
bool isKoboldFlame = false; // Model name matches kobold+(candle/torch/mine) (precomputed)
bool isLavaModel = false; // Model name contains lava/molten/magma (UV scroll fallback)
bool hasTextureAnimation = false; // True if any batch has UV animation
// Particle emitter data (kept from M2Model)
@ -181,9 +183,11 @@ struct M2Instance {
bool cachedHasParticleEmitters = false;
bool cachedIsGroundDetail = false;
bool cachedIsInvisibleTrap = false;
bool cachedIsInstancePortal = false;
bool cachedIsValid = false;
bool skipCollision = false; // WMO interior doodads — skip player wall collision
float cachedBoundRadius = 0.0f;
float portalSpinAngle = 0.0f; // Accumulated spin angle for portal rotation
// Frame-skip optimization (update distant animations less frequently)
uint8_t frameSkipCounter = 0;
@ -476,6 +480,7 @@ private:
// Smoke particle system
std::vector<SmokeParticle> smokeParticles;
std::vector<size_t> smokeInstanceIndices_; // Indices into instances[] for smoke emitters
std::vector<size_t> portalInstanceIndices_; // Indices into instances[] for spinning portals
static constexpr int MAX_SMOKE_PARTICLES = 1000;
float smokeEmitAccum = 0.0f;
std::mt19937 smokeRng{42};

View file

@ -244,6 +244,7 @@ private:
glm::vec3 shadowCenter = glm::vec3(0.0f);
bool shadowCenterInitialized = false;
bool shadowsEnabled = true;
float shadowDistance_ = 72.0f; // Shadow frustum half-extent (default: 72 units)
uint32_t shadowFrameCounter_ = 0;
@ -254,8 +255,13 @@ public:
void setShadowsEnabled(bool enabled) { shadowsEnabled = enabled; }
bool areShadowsEnabled() const { return shadowsEnabled; }
void setShadowDistance(float dist) { shadowDistance_ = glm::clamp(dist, 40.0f, 200.0f); }
float getShadowDistance() const { return shadowDistance_; }
void setMsaaSamples(VkSampleCountFlagBits samples);
void setWaterRefractionEnabled(bool enabled);
bool isWaterRefractionEnabled() const;
private:
void applyMsaaChange();
VkSampleCountFlagBits pendingMsaaSamples_ = VK_SAMPLE_COUNT_1_BIT;

View file

@ -93,12 +93,13 @@ public:
bool hasWater1xPass() const { return water1xRenderPass != VK_NULL_HANDLE; }
VkRenderPass getWater1xRenderPass() const { return water1xRenderPass; }
void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera, float time, bool use1x = false);
void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera, float time, bool use1x = false, uint32_t frameIndex = 0);
void captureSceneHistory(VkCommandBuffer cmd,
VkImage srcColorImage,
VkImage srcDepthImage,
VkExtent2D srcExtent,
bool srcDepthIsMsaa);
bool srcDepthIsMsaa,
uint32_t frameIndex = 0);
// --- Planar reflection pass ---
// Call sequence: beginReflectionPass → [render scene] → endReflectionPass
@ -124,6 +125,9 @@ public:
void setEnabled(bool enabled) { renderingEnabled = enabled; }
bool isEnabled() const { return renderingEnabled; }
void setRefractionEnabled(bool enabled);
bool isRefractionEnabled() const { return refractionEnabled; }
std::optional<float> getWaterHeightAt(float glX, float glY) const;
/// Like getWaterHeightAt but only returns water surfaces whose height is
/// close to the query Z (within maxAbove units above). Avoids false
@ -159,17 +163,22 @@ private:
VkDescriptorPool materialDescPool = VK_NULL_HANDLE;
VkDescriptorSetLayout sceneSetLayout = VK_NULL_HANDLE;
VkDescriptorPool sceneDescPool = VK_NULL_HANDLE;
VkDescriptorSet sceneSet = VK_NULL_HANDLE;
static constexpr uint32_t MAX_WATER_SETS = 16384;
VkSampler sceneColorSampler = VK_NULL_HANDLE;
VkSampler sceneDepthSampler = VK_NULL_HANDLE;
VkImage sceneColorImage = VK_NULL_HANDLE;
VmaAllocation sceneColorAlloc = VK_NULL_HANDLE;
VkImageView sceneColorView = VK_NULL_HANDLE;
VkImage sceneDepthImage = VK_NULL_HANDLE;
VmaAllocation sceneDepthAlloc = VK_NULL_HANDLE;
VkImageView sceneDepthView = VK_NULL_HANDLE;
// Per-frame scene history to avoid race between frames in flight
static constexpr uint32_t SCENE_HISTORY_FRAMES = 2;
struct PerFrameSceneHistory {
VkImage colorImage = VK_NULL_HANDLE;
VmaAllocation colorAlloc = VK_NULL_HANDLE;
VkImageView colorView = VK_NULL_HANDLE;
VkImage depthImage = VK_NULL_HANDLE;
VmaAllocation depthAlloc = VK_NULL_HANDLE;
VkImageView depthView = VK_NULL_HANDLE;
VkDescriptorSet sceneSet = VK_NULL_HANDLE;
};
PerFrameSceneHistory sceneHistory[SCENE_HISTORY_FRAMES];
VkExtent2D sceneHistoryExtent = {0, 0};
bool sceneHistoryReady = false;
mutable uint32_t renderDiagCounter_ = 0;
@ -200,6 +209,7 @@ private:
std::vector<WaterSurface> surfaces;
bool renderingEnabled = true;
bool refractionEnabled = false;
};
} // namespace rendering

View file

@ -196,6 +196,7 @@ public:
void setNormalMapStrength(float s) { normalMapStrength_ = s; materialSettingsDirty_ = true; }
void setPOMEnabled(bool enabled) { pomEnabled_ = enabled; materialSettingsDirty_ = true; }
void setPOMQuality(int q) { pomQuality_ = q; materialSettingsDirty_ = true; }
void setWMOOnlyMap(bool v) { wmoOnlyMap_ = v; materialSettingsDirty_ = true; }
bool isNormalMappingEnabled() const { return normalMappingEnabled_; }
float getNormalMapStrength() const { return normalMapStrength_; }
bool isPOMEnabled() const { return pomEnabled_; }
@ -339,7 +340,9 @@ private:
int32_t pomMaxSamples; // 36 (max ray-march steps)
float heightMapVariance; // 40 (low variance = skip POM)
float normalMapStrength; // 44 (0=flat, 1=full, 2=exaggerated)
}; // 48 bytes total
int32_t isLava; // 48 (1=lava/magma UV scroll)
float pad[3]; // 52-60 padding to 64 bytes
}; // 64 bytes total
/**
* WMO group GPU resources
@ -379,6 +382,7 @@ private:
bool unlit = false;
bool isTransparent = false; // blendMode >= 2
bool isWindow = false; // F_SIDN or F_WINDOW material
bool isLava = false; // lava/magma texture (UV scroll)
// For multi-draw: store index ranges
struct DrawRange { uint32_t firstIndex; uint32_t indexCount; };
std::vector<DrawRange> draws;
@ -670,6 +674,7 @@ private:
bool pomEnabled_ = true; // on by default
int pomQuality_ = 1; // 0=Low(16), 1=Medium(32), 2=High(64)
bool materialSettingsDirty_ = false; // rebuild UBOs when settings change
bool wmoOnlyMap_ = false; // true for dungeon/instance WMO-only maps
// Rendering state
bool wireframeMode = false;

View file

@ -87,6 +87,8 @@ private:
bool pendingVsync = false;
int pendingResIndex = 0;
bool pendingShadows = true;
float pendingShadowDistance = 72.0f;
bool pendingWaterRefraction = false;
int pendingMasterVolume = 100;
int pendingMusicVolume = 30;
int pendingAmbientVolume = 100;
@ -123,6 +125,7 @@ private:
bool minimapSettingsApplied_ = false;
bool volumeSettingsApplied_ = false; // True once saved volume settings applied to audio managers
bool msaaSettingsApplied_ = false; // True once saved MSAA setting applied to renderer
bool waterRefractionApplied_ = false;
bool normalMapSettingsApplied_ = false; // True once saved normal map/POM settings applied
// Mute state: mute bypasses master volume without touching slider values

View file

@ -968,6 +968,15 @@ void Application::update(float deltaTime) {
gameHandler->isTaxiMountActive() ||
gameHandler->isTaxiActivationPending());
bool onTransportNow = gameHandler && gameHandler->isOnTransport();
// M2 transports (trams) use position-delta approach: player keeps normal
// movement and the transport's frame-to-frame delta is applied on top.
// Only WMO transports (ships) use full external-driven mode.
bool isM2Transport = false;
if (onTransportNow && gameHandler->getTransportManager()) {
auto* tr = gameHandler->getTransportManager()->getTransport(gameHandler->getPlayerTransportGuid());
isM2Transport = (tr && tr->isM2);
}
bool onWMOTransport = onTransportNow && !isM2Transport;
if (worldEntryMovementGraceTimer_ > 0.0f) {
worldEntryMovementGraceTimer_ -= deltaTime;
// Clear stale movement from before teleport each frame
@ -976,7 +985,7 @@ void Application::update(float deltaTime) {
renderer->getCameraController()->clearMovementInputs();
}
if (renderer && renderer->getCameraController()) {
const bool externallyDrivenMotion = onTaxi || onTransportNow || chargeActive_;
const bool externallyDrivenMotion = onTaxi || onWMOTransport || chargeActive_;
// Keep physics frozen (externalFollow) during landing clamp when terrain
// hasn't loaded yet — prevents gravity from pulling player through void.
bool landingClampActive = !onTaxi && taxiLandingClampTimer_ > 0.0f &&
@ -1057,14 +1066,18 @@ void Application::update(float deltaTime) {
// Sync character render position ↔ canonical WoW coords each frame
if (renderer && gameHandler) {
bool onTransport = gameHandler->isOnTransport();
// For position sync branching, only WMO transports use the dedicated
// onTransport branch. M2 transports use the normal movement else branch
// with a position-delta correction applied on top.
bool onTransport = onWMOTransport;
// Debug: Log transport state changes
static bool wasOnTransport = false;
if (onTransport != wasOnTransport) {
LOG_DEBUG("Transport state changed: onTransport=", onTransport,
bool onTransportNowDbg = gameHandler->isOnTransport();
if (onTransportNowDbg != wasOnTransport) {
LOG_DEBUG("Transport state changed: onTransport=", onTransportNowDbg,
" isM2=", isM2Transport,
" guid=0x", std::hex, gameHandler->getPlayerTransportGuid(), std::dec);
wasOnTransport = onTransport;
wasOnTransport = onTransportNowDbg;
}
if (onTaxi) {
@ -1092,13 +1105,11 @@ void Application::update(float deltaTime) {
}
}
} else if (onTransport) {
// Transport mode: compose world position from transport transform + local offset
// WMO transport mode (ships): compose world position from transform + local offset
glm::vec3 canonical = gameHandler->getComposedWorldPosition();
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
renderer->getCharacterPosition() = renderPos;
// Keep movementInfo in lockstep with composed transport world position.
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
// Update camera follow target
if (renderer->getCameraController()) {
glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable();
if (followTarget) {
@ -1172,6 +1183,27 @@ void Application::update(float deltaTime) {
}
} else {
glm::vec3 renderPos = renderer->getCharacterPosition();
// M2 transport riding: apply transport's frame-to-frame position delta
// so the player moves with the tram while retaining normal movement input.
if (isM2Transport && gameHandler->getTransportManager()) {
auto* tr = gameHandler->getTransportManager()->getTransport(
gameHandler->getPlayerTransportGuid());
if (tr) {
static glm::vec3 lastTransportCanonical(0);
static uint64_t lastTransportGuid = 0;
if (lastTransportGuid == gameHandler->getPlayerTransportGuid()) {
glm::vec3 deltaCanonical = tr->position - lastTransportCanonical;
glm::vec3 deltaRender = core::coords::canonicalToRender(deltaCanonical)
- core::coords::canonicalToRender(glm::vec3(0));
renderPos += deltaRender;
renderer->getCharacterPosition() = renderPos;
}
lastTransportCanonical = tr->position;
lastTransportGuid = gameHandler->getPlayerTransportGuid();
}
}
glm::vec3 canonical = core::coords::renderToCanonical(renderPos);
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
@ -1203,6 +1235,41 @@ void Application::update(float deltaTime) {
facingSendCooldown_ = 0.1f; // max 10 Hz
}
}
// Client-side transport boarding detection (for M2 transports like trams
// where the server doesn't send transport attachment data).
// Use a generous AABB around each transport's current position.
if (gameHandler->getTransportManager() && !gameHandler->isOnTransport()) {
auto* tm = gameHandler->getTransportManager();
glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos);
for (auto& [guid, transport] : tm->getTransports()) {
if (!transport.isM2) continue;
glm::vec3 diff = playerCanonical - transport.position;
float horizDistSq = diff.x * diff.x + diff.y * diff.y;
float vertDist = std::abs(diff.z);
if (horizDistSq < 144.0f && vertDist < 15.0f) {
gameHandler->setPlayerOnTransport(guid, playerCanonical - transport.position);
LOG_DEBUG("M2 transport boarding: guid=0x", std::hex, guid, std::dec);
break;
}
}
}
// M2 transport disembark: player walked far enough from transport center
if (isM2Transport && gameHandler->getTransportManager()) {
auto* tm = gameHandler->getTransportManager();
auto* tr = tm->getTransport(gameHandler->getPlayerTransportGuid());
if (tr) {
glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos);
glm::vec3 diff = playerCanonical - tr->position;
float horizDistSq = diff.x * diff.x + diff.y * diff.y;
if (horizDistSq > 225.0f) {
gameHandler->clearPlayerTransport();
LOG_DEBUG("M2 transport disembark");
}
}
}
}
}
});
@ -1555,8 +1622,10 @@ void Application::setupUICallbacks() {
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
// Stop any movement that was active before the teleport
if (renderer->getCameraController())
if (renderer->getCameraController()) {
renderer->getCameraController()->clearMovementInputs();
renderer->getCameraController()->suppressMovementFor(0.5f);
}
return;
}
@ -1573,8 +1642,10 @@ void Application::setupUICallbacks() {
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
// Stop any movement that was active before the teleport
if (renderer && renderer->getCameraController())
if (renderer && renderer->getCameraController()) {
renderer->getCameraController()->clearMovementInputs();
renderer->getCameraController()->suppressMovementFor(1.0f);
}
loadOnlineWorldTerrain(mapId, x, y, z);
// loadedMapId_ is set inside loadOnlineWorldTerrain (including
// any deferred entries it processes), so we must NOT override it here.
@ -1667,13 +1738,17 @@ void Application::setupUICallbacks() {
}
// Sample floor at the DESTINATION position (after nudge).
// Pick the highest floor so we snap up to WMO floors when fallen below.
bool foundFloor = false;
if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 60.0f)) {
pos.z = *floor + 0.2f;
} else {
pos.z += 20.0f;
foundFloor = true;
}
cc->teleportTo(pos);
if (!foundFloor) {
cc->setGrounded(false); // Let gravity pull player down to a surface
}
syncTeleportedPositionToServer(pos);
forceServerTeleportCommand(pos);
clearStuckMovement();
@ -2065,7 +2140,7 @@ void Application::setupUICallbacks() {
}
uint32_t wmoInstanceId = it->second.instanceId;
LOG_DEBUG("Registering server transport: GUID=0x", std::hex, guid, std::dec,
LOG_WARNING("Registering server transport: GUID=0x", std::hex, guid, std::dec,
" entry=", entry, " displayId=", displayId, " wmoInstance=", wmoInstanceId,
" pos=(", x, ", ", y, ", ", z, ")");
@ -2093,15 +2168,18 @@ void Application::setupUICallbacks() {
hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f);
}
LOG_WARNING("Transport path check: entry=", entry, " hasUsablePath=", hasUsablePath,
" preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay);
if (preferServerData) {
// Strict server-authoritative mode: do not infer/remap fallback routes.
if (!hasUsablePath) {
std::vector<glm::vec3> path = { canonicalSpawnPos };
transportManager->loadPathFromNodes(pathId, path, false, 0.0f);
LOG_DEBUG("Server-first strict registration: stationary fallback for GUID 0x",
LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x",
std::hex, guid, std::dec, " entry=", entry);
} else {
LOG_DEBUG("Server-first transport registration: using entry DBC path for entry ", entry);
LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", entry);
}
} else if (!hasUsablePath) {
// Remap/infer path by spawn position when entry doesn't map 1:1 to DBC ids.
@ -2111,12 +2189,12 @@ void Application::setupUICallbacks() {
canonicalSpawnPos, 1200.0f, allowZOnly);
if (inferredPath != 0) {
pathId = inferredPath;
LOG_DEBUG("Using inferred transport path ", pathId, " for entry ", entry);
LOG_WARNING("Using inferred transport path ", pathId, " for entry ", entry);
} else {
uint32_t remappedPath = transportManager->pickFallbackMovingPath(entry, displayId);
if (remappedPath != 0) {
pathId = remappedPath;
LOG_DEBUG("Using remapped fallback transport path ", pathId,
LOG_WARNING("Using remapped fallback transport path ", pathId,
" for entry ", entry, " displayId=", displayId,
" (usableEntryPath=", transportManager->hasPathForEntry(entry), ")");
} else {
@ -2129,12 +2207,19 @@ void Application::setupUICallbacks() {
}
}
} else {
LOG_DEBUG("Using real transport path from TransportAnimation.dbc for entry ", entry);
LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", entry);
}
// Register the transport with spawn position (prevents rendering at origin until server update)
transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry);
// Mark M2 transports (e.g. Deeprun Tram cars) so TransportManager uses M2Renderer
if (!it->second.isWmo) {
if (auto* tr = transportManager->getTransport(guid)) {
tr->isM2 = true;
}
}
// Server-authoritative movement - set initial position from spawn data
glm::vec3 canonicalPos(x, y, z);
transportManager->updateServerTransport(guid, canonicalPos, orientation);
@ -2163,7 +2248,7 @@ void Application::setupUICallbacks() {
}
if (auto* tr = transportManager->getTransport(guid); tr) {
LOG_DEBUG("Transport registered: guid=0x", std::hex, guid, std::dec,
LOG_WARNING("Transport registered: guid=0x", std::hex, guid, std::dec,
" entry=", entry, " displayId=", displayId,
" pathId=", tr->pathId,
" mode=", (tr->useClientAnimation ? "client" : "server"),
@ -3428,16 +3513,22 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
glm::vec3 spawnCanonical = core::coords::serverToCanonical(glm::vec3(x, y, z));
glm::vec3 spawnRender = core::coords::canonicalToRender(spawnCanonical);
// Set camera position
// Set camera position and facing from server orientation
if (renderer->getCameraController()) {
float yawDeg = 0.0f;
if (gameHandler) {
float canonicalYaw = gameHandler->getMovementInfo().orientation;
yawDeg = 180.0f - glm::degrees(canonicalYaw);
}
renderer->getCameraController()->setOnlineMode(true);
renderer->getCameraController()->setDefaultSpawn(spawnRender, 0.0f, -15.0f);
renderer->getCameraController()->setDefaultSpawn(spawnRender, yawDeg, -15.0f);
renderer->getCameraController()->reset();
}
// Set map name for WMO renderer
// Set map name for WMO renderer and reset instance mode
if (renderer->getWMORenderer()) {
renderer->getWMORenderer()->setMapName(mapName);
renderer->getWMORenderer()->setWMOOnlyMap(false);
}
// Set map name for terrain manager
@ -3445,11 +3536,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
renderer->getTerrainManager()->setMapName(mapName);
}
// Connect TransportManager to WMORenderer (for server transports)
if (gameHandler && gameHandler->getTransportManager() && renderer->getWMORenderer()) {
gameHandler->getTransportManager()->setWMORenderer(renderer->getWMORenderer());
LOG_INFO("TransportManager connected to WMORenderer for online mode");
}
// NOTE: TransportManager renderer connection moved to after initializeRenderers (later in this function)
// Connect WMORenderer to M2Renderer (for hierarchical transforms: doodads following WMO parents)
if (renderer->getWMORenderer() && renderer->getM2Renderer()) {
@ -3548,6 +3635,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
// Set map name on WMO renderer and disable terrain streaming (no ADT tiles for instances)
if (renderer->getWMORenderer()) {
renderer->getWMORenderer()->setMapName(mapName);
renderer->getWMORenderer()->setWMOOnlyMap(true);
}
if (renderer->getTerrainManager()) {
renderer->getTerrainManager()->setStreamingEnabled(false);
@ -3707,8 +3795,8 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
}
if (!m2Model.isValid()) continue;
glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.y,
doodad.rotation.x, doodad.rotation.z);
glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.x,
doodad.rotation.y, doodad.rotation.z);
glm::mat4 doodadLocal(1.0f);
doodadLocal = glm::translate(doodadLocal, doodad.position);
doodadLocal *= glm::mat4_cast(fixedRotation);
@ -3719,7 +3807,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
uint32_t doodadModelId = static_cast<uint32_t>(std::hash<std::string>{}(m2Path));
m2Renderer->loadModel(m2Model, doodadModelId);
uint32_t doodadInstId = m2Renderer->createInstance(doodadModelId, worldPos, glm::vec3(0.0f), doodad.scale);
uint32_t doodadInstId = m2Renderer->createInstanceWithMatrix(doodadModelId, worldMatrix, worldPos);
if (doodadInstId) m2Renderer->setSkipCollision(doodadInstId, true);
loadedDoodads++;
}
@ -3918,9 +4006,18 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
renderer->getCameraController()->reset();
}
// Set up test transport (development feature)
// Test transport disabled — real transports come from server via UPDATEFLAG_TRANSPORT
showProgress("Finalizing world...", 0.94f);
setupTestTransport();
// setupTestTransport();
// Connect TransportManager to renderers (must happen AFTER initializeRenderers)
if (gameHandler && gameHandler->getTransportManager()) {
auto* tm = gameHandler->getTransportManager();
if (renderer->getWMORenderer()) tm->setWMORenderer(renderer->getWMORenderer());
if (renderer->getM2Renderer()) tm->setM2Renderer(renderer->getM2Renderer());
LOG_WARNING("TransportManager connected: wmoR=", (renderer->getWMORenderer() ? "yes" : "NULL"),
" m2R=", (renderer->getM2Renderer() ? "yes" : "NULL"));
}
// Set up NPC animation callbacks (for online creatures)
showProgress("Preparing creatures...", 0.97f);
@ -6355,6 +6452,10 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
} else if (displayId == 2454 || displayId == 181688 || displayId == 190536) {
modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo";
LOG_INFO("Overriding transport displayId ", displayId, " → Transport_Icebreaker_ship.wmo");
} else if (displayId == 3831) {
// Deeprun Tram car
modelPath = "World\\Generic\\Gnome\\Passive Doodads\\Subway\\SubwayCar.m2";
LOG_WARNING("Overriding transport displayId ", displayId, " → SubwayCar.m2");
}
}
@ -6495,7 +6596,12 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
// Transport GameObjects are not always named "transport" in their WMO path
// (e.g. elevators/lifts). If the server marks it as a transport, always
// notify so TransportManager can animate/carry passengers.
if (gameHandler && gameHandler->isTransportGuid(guid)) {
bool isTG = gameHandler && gameHandler->isTransportGuid(guid);
LOG_WARNING("WMO GO spawned: guid=0x", std::hex, guid, std::dec,
" entry=", entry, " displayId=", displayId,
" isTransport=", isTG,
" pos=(", x, ", ", y, ", ", z, ")");
if (isTG) {
gameHandler->notifyTransportSpawned(guid, entry, displayId, x, y, z, orientation);
}
@ -6559,17 +6665,27 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
return;
}
// Freeze animation for static gameobjects, but let portals/effects animate
// Freeze animation for static gameobjects, but let portals/effects/transports animate
bool isTransportGO = gameHandler && gameHandler->isTransportGuid(guid);
std::string lowerPath = modelPath;
std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), ::tolower);
bool isAnimatedEffect = (lowerPath.find("instanceportal") != std::string::npos ||
lowerPath.find("instancenewportal") != std::string::npos ||
lowerPath.find("portalfx") != std::string::npos ||
lowerPath.find("spellportal") != std::string::npos);
if (!isAnimatedEffect) {
if (!isAnimatedEffect && !isTransportGO) {
m2Renderer->setInstanceAnimationFrozen(instanceId, true);
}
gameObjectInstances_[guid] = {modelId, instanceId, false};
// Notify transport system for M2 transports (e.g. Deeprun Tram cars)
if (gameHandler && gameHandler->isTransportGuid(guid)) {
LOG_WARNING("M2 transport spawned: guid=0x", std::hex, guid, std::dec,
" entry=", entry, " displayId=", displayId,
" instanceId=", instanceId);
gameHandler->notifyTransportSpawned(guid, entry, displayId, x, y, z, orientation);
}
}
LOG_DEBUG("Spawned gameobject: guid=0x", std::hex, guid, std::dec,

View file

@ -4936,10 +4936,17 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec,
" offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")");
} else {
if (playerTransportGuid_ != 0) {
LOG_INFO("Player left transport");
// Don't clear client-side M2 transport boarding (trams) —
// the server doesn't know about client-detected transport attachment.
bool isClientM2Transport = false;
if (playerTransportGuid_ != 0 && transportManager_) {
auto* tr = transportManager_->getTransport(playerTransportGuid_);
isClientM2Transport = (tr && tr->isM2);
}
if (playerTransportGuid_ != 0 && !isClientM2Transport) {
LOG_INFO("Player left transport");
clearPlayerTransport();
}
clearPlayerTransport();
}
}
@ -5173,9 +5180,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
queryGameObjectInfo(itEntry->second, block.guid);
}
// Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002)
LOG_WARNING("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec,
" entry=", go->getEntry(), " displayId=", go->getDisplayId(),
" updateFlags=0x", std::hex, block.updateFlags, std::dec,
" pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")");
if (block.updateFlags & 0x0002) {
transportGuids_.insert(block.guid);
LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec,
LOG_WARNING("Detected transport GameObject: 0x", std::hex, block.guid, std::dec,
" entry=", go->getEntry(),
" displayId=", go->getDisplayId(),
" pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")");
@ -5691,7 +5702,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
movementInfo.x = pos.x;
movementInfo.y = pos.y;
movementInfo.z = pos.z;
if (playerTransportGuid_ != 0) {
// Don't clear client-side M2 transport boarding
bool isClientM2Transport = false;
if (playerTransportGuid_ != 0 && transportManager_) {
auto* tr = transportManager_->getTransport(playerTransportGuid_);
isClientM2Transport = (tr && tr->isM2);
}
if (playerTransportGuid_ != 0 && !isClientM2Transport) {
LOG_INFO("Player left transport (MOVEMENT)");
clearPlayerTransport();
}
@ -8814,16 +8831,16 @@ void GameHandler::checkAreaTriggers() {
bool inside = false;
if (at.radius > 0.0f) {
// Sphere trigger — small minimum so player must be near the portal
float effectiveRadius = std::max(at.radius, 12.0f);
// Sphere trigger — use actual radius, with small floor for very tiny triggers
float effectiveRadius = std::max(at.radius, 3.0f);
float dx = px - at.x;
float dy = py - at.y;
float dz = pz - at.z;
float distSq = dx * dx + dy * dy + dz * dz;
inside = (distSq <= effectiveRadius * effectiveRadius);
} else if (at.boxLength > 0.0f || at.boxWidth > 0.0f || at.boxHeight > 0.0f) {
// Box trigger — small minimum so player must walk into the portal area
float boxMin = 16.0f;
// Box trigger — use actual size, with small floor for tiny triggers
float boxMin = 4.0f;
float effLength = std::max(at.boxLength, boxMin);
float effWidth = std::max(at.boxWidth, boxMin);
float effHeight = std::max(at.boxHeight, boxMin);

View file

@ -1,5 +1,6 @@
#include "game/transport_manager.hpp"
#include "rendering/wmo_renderer.hpp"
#include "rendering/m2_renderer.hpp"
#include "core/coordinates.hpp"
#include "core/logger.hpp"
#include "pipeline/dbc_loader.hpp"
@ -80,10 +81,11 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId,
transport.localClockMs = 0;
transport.hasServerClock = false;
transport.serverClockOffsetMs = 0;
// Default is server-authoritative movement.
// Exception: elevator-style transports (z-only DBC paths) often do not stream continuous
// movement updates from the server, but the client is expected to animate them.
transport.useClientAnimation = (path.fromDBC && path.zOnly && path.durationMs > 0);
// Start with client-side animation for all DBC paths with real movement.
// If the server sends actual position updates, updateServerTransport() will switch
// to server-driven mode. This ensures transports like trams (which the server doesn't
// stream updates for) still animate, while ships/zeppelins switch to server authority.
transport.useClientAnimation = (path.fromDBC && path.durationMs > 0);
transport.clientAnimationReverse = false;
transport.serverYaw = 0.0f;
transport.hasServerYaw = false;
@ -98,16 +100,19 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId,
if (transport.useClientAnimation && path.durationMs > 0) {
// Seed to a stable phase based on our local clock so elevators don't all start at t=0.
transport.localClockMs = static_cast<uint32_t>(elapsedTime_ * 1000.0f) % path.durationMs;
LOG_INFO("TransportManager: Enabled client animation for z-only transport 0x",
LOG_INFO("TransportManager: Enabled client animation for transport 0x",
std::hex, guid, std::dec, " path=", pathId,
" durationMs=", path.durationMs, " seedMs=", transport.localClockMs);
" durationMs=", path.durationMs, " seedMs=", transport.localClockMs,
(path.worldCoords ? " [worldCoords]" : (path.zOnly ? " [z-only]" : "")));
}
updateTransformMatrices(transport);
// CRITICAL: Update WMO renderer with initial transform
if (wmoRenderer_) {
wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
if (transport.isM2) {
if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
} else {
if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
}
transports_[guid] = transport;
@ -140,6 +145,14 @@ glm::vec3 TransportManager::getPlayerWorldPosition(uint64_t transportGuid, const
return localOffset; // Fallback
}
if (transport->isM2) {
// M2 transports (trams): localOffset is a canonical world-space delta
// from the transport's canonical position. Just add directly.
return transport->position + localOffset;
}
// WMO transports (ships): localOffset is in transport-local space,
// use the render-space transform matrix.
glm::vec4 localPos(localOffset, 1.0f);
glm::vec4 worldPos = transport->transform * localPos;
return glm::vec3(worldPos);
@ -284,14 +297,17 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float
glm::vec3 pathOffset = evalTimedCatmullRom(path, pathTimeMs);
// Guard against bad fallback Z curves on some remapped transport paths (notably icebreakers),
// where path offsets can sink far below sea level when we only have spawn-time data.
if (transport.useClientAnimation && transport.serverUpdateCount <= 1) {
constexpr float kMinFallbackZOffset = -2.0f;
pathOffset.z = glm::max(pathOffset.z, kMinFallbackZOffset);
}
if (!transport.useClientAnimation && !transport.hasServerClock) {
constexpr float kMinFallbackZOffset = -2.0f;
constexpr float kMaxFallbackZOffset = 8.0f;
pathOffset.z = glm::clamp(pathOffset.z, kMinFallbackZOffset, kMaxFallbackZOffset);
// Skip Z clamping for world-coordinate paths (TaxiPathNode) where values are absolute positions.
if (!path.worldCoords) {
if (transport.useClientAnimation && transport.serverUpdateCount <= 1) {
constexpr float kMinFallbackZOffset = -2.0f;
pathOffset.z = glm::max(pathOffset.z, kMinFallbackZOffset);
}
if (!transport.useClientAnimation && !transport.hasServerClock) {
constexpr float kMinFallbackZOffset = -2.0f;
constexpr float kMaxFallbackZOffset = 8.0f;
pathOffset.z = glm::clamp(pathOffset.z, kMinFallbackZOffset, kMaxFallbackZOffset);
}
}
transport.position = transport.basePosition + pathOffset;
@ -307,24 +323,20 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float
updateTransformMatrices(transport);
// Update WMO instance position
if (wmoRenderer_) {
wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
if (transport.isM2) {
if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
} else {
if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
}
// Debug logging every 120 frames (~2 seconds at 60fps)
// Debug logging every 600 frames (~10 seconds at 60fps)
static int debugFrameCount = 0;
if (debugFrameCount++ % 120 == 0) {
// Log canonical position AND render position to check coordinate conversion
glm::vec3 renderPos = core::coords::canonicalToRender(transport.position);
if (debugFrameCount++ % 600 == 0) {
LOG_DEBUG("Transport 0x", std::hex, transport.guid, std::dec,
" pathTime=", pathTimeMs, "ms / ", path.durationMs, "ms",
" canonicalPos=(", transport.position.x, ", ", transport.position.y, ", ", transport.position.z, ")",
" renderPos=(", renderPos.x, ", ", renderPos.y, ", ", renderPos.z, ")",
" basePos=(", transport.basePosition.x, ", ", transport.basePosition.y, ", ", transport.basePosition.z, ")",
" pathOffset=(", pathOffset.x, ", ", pathOffset.y, ", ", pathOffset.z, ")",
" pos=(", transport.position.x, ", ", transport.position.y, ", ", transport.position.z, ")",
" mode=", (transport.useClientAnimation ? "client" : "server"),
" hasServerClock=", transport.hasServerClock,
" offset=", transport.serverClockOffsetMs, "ms");
" isM2=", transport.isM2);
}
}
@ -561,12 +573,24 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos
// Track server updates
transport->serverUpdateCount++;
transport->lastServerUpdate = elapsedTime_;
// Server updates take precedence for moving XY transports, but z-only elevators should
// remain client-animated (server may only send sparse state updates).
if (!isZOnlyPath) {
transport->useClientAnimation = false;
} else {
// Z-only elevators and world-coordinate paths (TaxiPathNode) always stay client-driven.
// For other DBC paths (trams, ships): only switch to server-driven mode when the server
// sends a position that actually differs from the current position, indicating it's
// actively streaming movement data (not just echoing the spawn position).
if (isZOnlyPath || isWorldCoordPath) {
transport->useClientAnimation = true;
} else if (transport->useClientAnimation && hasPath && pathIt->second.fromDBC) {
float posDelta = glm::length(position - transport->position);
if (posDelta > 1.0f) {
// Server sent a meaningfully different position — it's actively driving this transport
transport->useClientAnimation = false;
LOG_INFO("Transport 0x", std::hex, guid, std::dec,
" switching to server-driven (posDelta=", posDelta, ")");
}
// Otherwise keep client animation (server just echoed spawn pos or sent small jitter)
} else if (!hasPath || !pathIt->second.fromDBC) {
// No DBC path — purely server-driven
transport->useClientAnimation = false;
}
transport->clientAnimationReverse = false;
@ -576,8 +600,10 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos
transport->position = position;
transport->rotation = glm::angleAxis(orientation, glm::vec3(0.0f, 0.0f, 1.0f));
updateTransformMatrices(*transport);
if (wmoRenderer_) {
wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform);
if (transport->isM2) {
if (m2Renderer_) m2Renderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform);
} else {
if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform);
}
return;
}
@ -846,12 +872,23 @@ bool TransportManager::loadTransportAnimationDBC(pipeline::AssetManager* assetMg
std::vector<TimedPoint> timedPoints;
timedPoints.reserve(sortedWaypoints.size() + 1); // +1 for wrap point
// Log first few waypoints for transport 2074 to see conversion
// Log DBC waypoints for tram entries
if (transportEntry >= 176080 && transportEntry <= 176085) {
size_t mid = sortedWaypoints.size() / 4; // ~quarter through
size_t mid2 = sortedWaypoints.size() / 2; // ~halfway
LOG_WARNING("DBC path entry=", transportEntry, " nPts=", sortedWaypoints.size(),
" [0] t=", sortedWaypoints[0].first, " raw=(", sortedWaypoints[0].second.x, ",", sortedWaypoints[0].second.y, ",", sortedWaypoints[0].second.z, ")",
" [", mid, "] t=", sortedWaypoints[mid].first, " raw=(", sortedWaypoints[mid].second.x, ",", sortedWaypoints[mid].second.y, ",", sortedWaypoints[mid].second.z, ")",
" [", mid2, "] t=", sortedWaypoints[mid2].first, " raw=(", sortedWaypoints[mid2].second.x, ",", sortedWaypoints[mid2].second.y, ",", sortedWaypoints[mid2].second.z, ")");
}
for (size_t idx = 0; idx < sortedWaypoints.size(); idx++) {
const auto& [tMs, pos] = sortedWaypoints[idx];
// TransportAnimation.dbc uses server coordinates - convert to canonical
glm::vec3 canonical = core::coords::serverToCanonical(pos);
// TransportAnimation.dbc local offsets use a coordinate system where
// the travel axis is negated relative to server world coords.
// Negate X and Y before converting to canonical (Z=height stays the same).
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(-pos.x, -pos.y, pos.z));
// CRITICAL: Detect if serverToCanonical is zeroing nonzero inputs
if ((pos.x != 0.0f || pos.y != 0.0f || pos.z != 0.0f) &&
@ -896,7 +933,8 @@ bool TransportManager::loadTransportAnimationDBC(pipeline::AssetManager* assetMg
// Add duplicate first point at end with wrap duration
// This makes the wrap segment (last → first) have proper duration
glm::vec3 firstCanonical = core::coords::serverToCanonical(sortedWaypoints.front().second);
const auto& fp = sortedWaypoints.front().second;
glm::vec3 firstCanonical = core::coords::serverToCanonical(glm::vec3(-fp.x, -fp.y, fp.z));
timedPoints.push_back({lastTimeMs + wrapMs, firstCanonical});
uint32_t durationMs = lastTimeMs + wrapMs;

View file

@ -200,16 +200,22 @@ void CameraController::update(float deltaTime) {
// Don't process keyboard input when UI text input (e.g. chat box) has focus
bool uiWantsKeyboard = ImGui::GetIO().WantTextInput;
// Suppress movement input after teleport/portal (keys may still be held)
if (movementSuppressTimer_ > 0.0f) {
movementSuppressTimer_ -= deltaTime;
}
bool movementSuppressed = movementSuppressTimer_ > 0.0f;
// Determine current key states
bool keyW = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_W);
bool keyS = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_S);
bool keyA = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_A);
bool keyD = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_D);
bool keyQ = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_Q);
bool keyE = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_E);
bool keyW = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_W);
bool keyS = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_S);
bool keyA = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_A);
bool keyD = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_D);
bool keyQ = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_Q);
bool keyE = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_E);
bool shiftDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT));
bool ctrlDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL));
bool nowJump = !uiWantsKeyboard && !sitting && input.isKeyJustPressed(SDL_SCANCODE_SPACE);
bool nowJump = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyJustPressed(SDL_SCANCODE_SPACE);
// Idle camera: any input resets the timer; timeout triggers a slow orbit pan
bool anyInput = leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump;
@ -626,7 +632,8 @@ void CameraController::update(float deltaTime) {
glm::vec3 stepPos = swimFrom;
if (swimMoveDist > 0.01f) {
int swimSteps = std::max(1, std::min(3, static_cast<int>(std::ceil(swimMoveDist / 0.65f))));
float swimStepSize = cachedInsideWMO ? 0.20f : 0.35f;
int swimSteps = std::max(1, std::min(8, static_cast<int>(std::ceil(swimMoveDist / swimStepSize))));
glm::vec3 stepDelta = (swimTo - swimFrom) / static_cast<float>(swimSteps);
for (int i = 0; i < swimSteps; i++) {
@ -634,7 +641,7 @@ void CameraController::update(float deltaTime) {
if (wmoRenderer) {
glm::vec3 adjusted;
if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) {
if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted, cachedInsideWMO)) {
candidate.x = adjusted.x;
candidate.y = adjusted.y;
candidate.z = std::max(candidate.z, adjusted.z);
@ -1274,8 +1281,10 @@ void CameraController::update(float deltaTime) {
lastInsideWMOCheckPos = targetPos;
}
// Do not clamp zoom target by ceiling checks. First-person should always
// be reachable; occlusion handling below will resolve camera placement safely.
// Smoothly pull camera in when entering WMO interiors
if (cachedInsideWMO && userTargetDistance > MAX_DISTANCE_INTERIOR) {
userTargetDistance = MAX_DISTANCE_INTERIOR;
}
}
// ===== Camera collision (sphere sweep approximation) =====
@ -1499,14 +1508,15 @@ void CameraController::update(float deltaTime) {
float moveDist = glm::length(desiredFeet - startFeet);
if (moveDist > 0.01f) {
int sweepSteps = std::max(1, std::min(3, static_cast<int>(std::ceil(moveDist / 0.65f))));
float stepSize = cachedInsideWMO ? 0.20f : 0.35f;
int sweepSteps = std::max(1, std::min(8, static_cast<int>(std::ceil(moveDist / stepSize))));
glm::vec3 stepPos = startFeet;
glm::vec3 stepDelta = (desiredFeet - startFeet) / static_cast<float>(sweepSteps);
for (int i = 0; i < sweepSteps; i++) {
glm::vec3 candidate = stepPos + stepDelta;
glm::vec3 adjusted;
if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) {
if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted, cachedInsideWMO)) {
candidate.x = adjusted.x;
candidate.y = adjusted.y;
candidate.z = std::max(candidate.z, adjusted.z);
@ -1985,6 +1995,7 @@ void CameraController::processMouseWheel(float delta) {
float zoomSpeed = glm::max(userTargetDistance * 0.15f, 0.3f);
userTargetDistance -= delta * zoomSpeed;
float maxDist = extendedZoom_ ? MAX_DISTANCE_EXTENDED : MAX_DISTANCE_NORMAL;
if (cachedInsideWMO) maxDist = std::min(maxDist, MAX_DISTANCE_INTERIOR);
userTargetDistance = glm::clamp(userTargetDistance, MIN_DISTANCE, maxDist);
}

View file

@ -1116,9 +1116,39 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
// Ground clutter (grass/pebbles/detail cards) should never block camera/movement.
gpuModel.collisionNoBlock = true;
}
// Spell effect models: particle-dominated with minimal geometry (e.g. LevelUp.m2)
gpuModel.isSpellEffect = hasParticles && model.vertices.size() <= 200 &&
model.particleEmitters.size() >= 3;
// Spell effect / pure-visual models: particle-dominated with minimal geometry,
// or named effect models (light shafts, portals, emitters, spotlights)
bool effectByName =
(lowerName.find("lightshaft") != std::string::npos) ||
(lowerName.find("volumetriclight") != std::string::npos) ||
(lowerName.find("instanceportal") != std::string::npos) ||
(lowerName.find("instancenewportal") != std::string::npos) ||
(lowerName.find("mageportal") != std::string::npos) ||
(lowerName.find("worldtreeportal") != std::string::npos) ||
(lowerName.find("particleemitter") != std::string::npos) ||
(lowerName.find("bubbles") != std::string::npos) ||
(lowerName.find("spotlight") != std::string::npos) ||
(lowerName.find("hazardlight") != std::string::npos) ||
(lowerName.find("lavasplash") != std::string::npos) ||
(lowerName.find("lavabubble") != std::string::npos) ||
(lowerName.find("lavasteam") != std::string::npos) ||
(lowerName.find("wisps") != std::string::npos);
gpuModel.isSpellEffect = effectByName ||
(hasParticles && model.vertices.size() <= 200 &&
model.particleEmitters.size() >= 3);
gpuModel.isLavaModel =
(lowerName.find("forgelava") != std::string::npos) ||
(lowerName.find("lavapot") != std::string::npos) ||
(lowerName.find("lavaflow") != std::string::npos);
gpuModel.isInstancePortal =
(lowerName.find("instanceportal") != std::string::npos) ||
(lowerName.find("instancenewportal") != std::string::npos) ||
(lowerName.find("portalfx") != std::string::npos) ||
(lowerName.find("spellportal") != std::string::npos);
// Instance portals are spell effects too (additive blend, no collision)
if (gpuModel.isInstancePortal) {
gpuModel.isSpellEffect = true;
}
// Water vegetation: cattails, reeds, bulrushes, kelp, seaweed, lilypad near water
gpuModel.isWaterVegetation =
(lowerName.find("cattail") != std::string::npos) ||
@ -1619,6 +1649,7 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position,
instance.cachedBoundRadius = mdlRef.boundRadius;
instance.cachedIsGroundDetail = mdlRef.isGroundDetail;
instance.cachedIsInvisibleTrap = mdlRef.isInvisibleTrap;
instance.cachedIsInstancePortal = mdlRef.isInstancePortal;
instance.cachedIsValid = mdlRef.isValid();
// Initialize animation: play first sequence (usually Stand/Idle)
@ -1637,6 +1668,9 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position,
if (mdlRef.isSmoke) {
smokeInstanceIndices_.push_back(idx);
}
if (mdlRef.isInstancePortal) {
portalInstanceIndices_.push_back(idx);
}
if (!mdlRef.particleEmitters.empty()) {
particleInstanceIndices_.push_back(idx);
}
@ -1926,6 +1960,18 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
++i;
}
// --- Spin instance portals ---
static constexpr float PORTAL_SPIN_SPEED = 1.2f; // radians/sec
for (size_t idx : portalInstanceIndices_) {
if (idx >= instances.size()) continue;
auto& inst = instances[idx];
inst.portalSpinAngle += PORTAL_SPIN_SPEED * deltaTime;
if (inst.portalSpinAngle > 6.2831853f)
inst.portalSpinAngle -= 6.2831853f;
inst.rotation.z = inst.portalSpinAngle;
inst.updateModelMatrix();
}
// --- Normal M2 animation update ---
// Advance animTime for ALL instances (needed for texture UV animation on static doodads).
// This is a tight loop touching only one float per instance — no hash lookups.
@ -2236,6 +2282,22 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
if (model.isGroundDetail) {
instanceFadeAlpha *= 0.82f;
}
if (model.isInstancePortal) {
// Render mesh at low alpha + emit glow sprite at center
instanceFadeAlpha *= 0.12f;
if (entry.distSq < 400.0f * 400.0f) {
glm::vec3 center = glm::vec3(instance.modelMatrix * glm::vec4(0.0f, 0.0f, 0.0f, 1.0f));
GlowSprite gs;
gs.worldPos = center;
gs.color = glm::vec4(0.35f, 0.5f, 1.0f, 1.1f);
gs.size = instance.scale * 5.0f;
glowSprites_.push_back(gs);
GlowSprite halo = gs;
halo.color.a *= 0.3f;
halo.size *= 2.2f;
glowSprites_.push_back(halo);
}
}
// Upload bone matrices to SSBO if model has skeletal animation
bool useBones = model.hasAnimation && !model.disableAnimation && !instance.boneMatrices.empty();
@ -2300,6 +2362,9 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
}
const bool foliageLikeModel = model.isFoliageLike;
// Particle-dominant spell effects: mesh is emission geometry, render dim
const bool particleDominantEffect = model.isSpellEffect &&
!model.particleEmitters.empty() && model.batches.size() <= 2;
for (const auto& batch : model.batches) {
if (batch.indexCount == 0) continue;
if (!model.isGroundDetail && batch.submeshLevel != targetLOD) continue;
@ -2364,6 +2429,12 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
}
}
}
// Lava M2 models: fallback UV scroll if no texture animation
if (model.isLavaModel && uvOffset == glm::vec2(0.0f)) {
static auto startTime = std::chrono::steady_clock::now();
float t = std::chrono::duration<float>(std::chrono::steady_clock::now() - startTime).count();
uvOffset = glm::vec2(t * 0.03f, -t * 0.08f);
}
// Foliage/card-like batches render more stably as cutout (depth-write on)
// instead of alpha-blended sorting.
@ -2381,8 +2452,14 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
// Select pipeline based on blend mode
uint8_t effectiveBlendMode = batch.blendMode;
if (model.isSpellEffect && (effectiveBlendMode == 4 || effectiveBlendMode == 5)) {
effectiveBlendMode = 3;
if (model.isSpellEffect) {
// Effect models: force additive blend for opaque/cutout batches
// so the mesh renders as a transparent glow, not a solid object
if (effectiveBlendMode <= 1) {
effectiveBlendMode = 3; // additive
} else if (effectiveBlendMode == 4 || effectiveBlendMode == 5) {
effectiveBlendMode = 3;
}
}
if (forceCutout) {
effectiveBlendMode = 1;
@ -2435,6 +2512,10 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
pc.useBones = useBones ? 1 : 0;
pc.isFoliage = model.shadowWindFoliage ? 1 : 0;
pc.fadeAlpha = instanceFadeAlpha;
// Particle-dominant effects: mesh is emission geometry, don't render
if (particleDominantEffect && batch.blendMode <= 1) {
continue;
}
vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc);
vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0);
@ -3398,6 +3479,7 @@ void M2Renderer::clear() {
instanceIndexById.clear();
smokeParticles.clear();
smokeInstanceIndices_.clear();
portalInstanceIndices_.clear();
animatedInstanceIndices_.clear();
particleOnlyInstanceIndices_.clear();
particleInstanceIndices_.clear();
@ -3433,6 +3515,7 @@ void M2Renderer::rebuildSpatialIndex() {
instanceIndexById.clear();
instanceIndexById.reserve(instances.size());
smokeInstanceIndices_.clear();
portalInstanceIndices_.clear();
animatedInstanceIndices_.clear();
particleOnlyInstanceIndices_.clear();
particleInstanceIndices_.clear();
@ -3444,6 +3527,9 @@ void M2Renderer::rebuildSpatialIndex() {
if (inst.cachedIsSmoke) {
smokeInstanceIndices_.push_back(i);
}
if (inst.cachedIsInstancePortal) {
portalInstanceIndices_.push_back(i);
}
if (inst.cachedHasParticleEmitters) {
particleInstanceIndices_.push_back(i);
}

View file

@ -855,6 +855,14 @@ void Renderer::unregisterPreview(CharacterPreview* preview) {
}
}
void Renderer::setWaterRefractionEnabled(bool enabled) {
if (waterRenderer) waterRenderer->setRefractionEnabled(enabled);
}
bool Renderer::isWaterRefractionEnabled() const {
return waterRenderer && waterRenderer->isRefractionEnabled();
}
void Renderer::setMsaaSamples(VkSampleCountFlagBits samples) {
if (!vkCtx) return;
@ -1054,20 +1062,27 @@ void Renderer::endFrame() {
vkCmdEndRenderPass(currentCmd);
// Scene-history capture is disabled: with MAX_FRAMES_IN_FLIGHT=2, the single
// sceneColorImage can race between frame N-1's water shader read and frame N's
// transfer write, eventually causing VK_ERROR_DEVICE_LOST. Water renders
// without refraction until per-frame scene-history images are implemented.
// TODO: allocate per-frame sceneColor/Depth images to fix the race.
uint32_t frame = vkCtx->getCurrentFrame();
// Render water in separate 1x pass (without scene refraction for now)
// Capture scene color/depth into per-frame history images for water refraction
if (waterRenderer && waterRenderer->isRefractionEnabled() && waterRenderer->hasSurfaces()
&& currentImageIndex < vkCtx->getSwapchainImages().size()) {
waterRenderer->captureSceneHistory(
currentCmd,
vkCtx->getSwapchainImages()[currentImageIndex],
vkCtx->getDepthCopySourceImage(),
vkCtx->getSwapchainExtent(),
vkCtx->isDepthCopySourceMsaa(),
frame);
}
// Render water in separate 1x pass after MSAA resolve + scene capture
bool waterDeferred = waterRenderer && waterRenderer->hasSurfaces() && waterRenderer->hasWater1xPass()
&& vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT;
if (waterDeferred && camera) {
VkExtent2D ext = vkCtx->getSwapchainExtent();
uint32_t frame = vkCtx->getCurrentFrame();
if (waterRenderer->beginWater1xPass(currentCmd, currentImageIndex, ext)) {
waterRenderer->render(currentCmd, perFrameDescSets[frame], *camera, globalTime, true);
waterRenderer->render(currentCmd, perFrameDescSets[frame], *camera, globalTime, true, frame);
waterRenderer->endWater1xPass(currentCmd);
}
}
@ -3268,7 +3283,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
bool waterDeferred = waterRenderer && waterRenderer->hasWater1xPass()
&& vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT;
if (waterRenderer && camera && !waterDeferred) {
waterRenderer->render(currentCmd, perFrameSet, *camera, globalTime);
waterRenderer->render(currentCmd, perFrameSet, *camera, globalTime, false, vkCtx->getCurrentFrame());
}
// Weather particles
@ -3751,10 +3766,10 @@ void Renderer::renderHUD() {
// in createPerFrameResources() as part of the Vulkan shadow infrastructure.
glm::mat4 Renderer::computeLightSpaceMatrix() {
constexpr float kShadowHalfExtent = 180.0f;
constexpr float kShadowLightDistance = 280.0f;
const float kShadowHalfExtent = shadowDistance_;
const float kShadowLightDistance = shadowDistance_ * 3.0f;
constexpr float kShadowNearPlane = 1.0f;
constexpr float kShadowFarPlane = 600.0f;
const float kShadowFarPlane = shadowDistance_ * 6.5f;
// Use active lighting direction so shadow projection matches main shading.
// Fragment shaders derive lighting with `ldir = normalize(-lightDir.xyz)`,
@ -3905,18 +3920,7 @@ void Renderer::renderShadowPass() {
if (!shadowsEnabled || shadowDepthImage == VK_NULL_HANDLE) return;
if (currentCmd == VK_NULL_HANDLE) return;
const int baseInterval = std::max(1, envIntOrDefault("WOWEE_SHADOW_INTERVAL", 1));
const int denseInterval = std::max(baseInterval, envIntOrDefault("WOWEE_SHADOW_INTERVAL_DENSE", 3));
const uint32_t denseCharThreshold = static_cast<uint32_t>(std::max(1, envIntOrDefault("WOWEE_DENSE_CHAR_THRESHOLD", 120)));
const uint32_t denseM2Threshold = static_cast<uint32_t>(std::max(1, envIntOrDefault("WOWEE_DENSE_M2_THRESHOLD", 900)));
const bool denseScene =
(characterRenderer && characterRenderer->getInstanceCount() >= denseCharThreshold) ||
(m2Renderer && m2Renderer->getInstanceCount() >= denseM2Threshold);
const int shadowInterval = denseScene ? denseInterval : baseInterval;
if (++shadowFrameCounter_ < static_cast<uint32_t>(shadowInterval)) {
return;
}
shadowFrameCounter_ = 0;
// Shadows render every frame — throttling causes visible flicker on player/NPCs
// Compute and store light space matrix; write to per-frame UBO
lightSpaceMatrix = computeLightSpaceMatrix();
@ -3969,9 +3973,7 @@ void Renderer::renderShadowPass() {
vkCmdSetScissor(currentCmd, 0, 1, &sc);
// Phase 7/8: render shadow casters
const float baseShadowCullRadius = static_cast<float>(std::max(40, envIntOrDefault("WOWEE_SHADOW_CULL_RADIUS", 180)));
const float denseShadowCullRadius = static_cast<float>(std::max(30, envIntOrDefault("WOWEE_SHADOW_CULL_RADIUS_DENSE", 90)));
const float shadowCullRadius = denseScene ? std::min(baseShadowCullRadius, denseShadowCullRadius) : baseShadowCullRadius;
const float shadowCullRadius = shadowDistance_ * 1.35f;
if (wmoRenderer) {
wmoRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, shadowCullRadius);
}

View file

@ -567,8 +567,7 @@ std::shared_ptr<PendingTile> TerrainManager::prepareTile(int x, int y) {
// Build doodad's local transform (WoW coordinates)
// WMO doodads use quaternion rotation
// Fix: WoW quaternions need X/Y swap for correct orientation
glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.y, doodad.rotation.x, doodad.rotation.z);
glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.x, doodad.rotation.y, doodad.rotation.z);
glm::mat4 doodadLocal(1.0f);
doodadLocal = glm::translate(doodadLocal, doodad.position);
@ -838,8 +837,12 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) {
modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f));
for (const auto& group : wmoReady.model.groups) {
if (!group.liquid.hasLiquid()) continue;
// Skip interior groups — their liquid is for indoor areas
if (group.flags & 0x2000) continue;
// Skip interior water/ocean but keep magma/slime (e.g. Ironforge lava)
if (group.flags & 0x2000) {
uint16_t lt = group.liquid.materialId;
uint8_t basicType = (lt == 0) ? 0 : ((lt - 1) % 4);
if (basicType < 2) continue;
}
waterRenderer->loadFromWMO(group.liquid, modelMatrix, wmoInstId);
loadedLiquids++;
}

View file

@ -118,15 +118,15 @@ bool WaterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLay
return false;
}
// Pool needs 3 combined image samplers + 1 uniform buffer
// Pool needs 3 combined image samplers + 1 uniform buffer per frame
std::array<VkDescriptorPoolSize, 2> scenePoolSizes{};
scenePoolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
scenePoolSizes[0].descriptorCount = 3;
scenePoolSizes[0].descriptorCount = 3 * SCENE_HISTORY_FRAMES;
scenePoolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
scenePoolSizes[1].descriptorCount = 1;
scenePoolSizes[1].descriptorCount = SCENE_HISTORY_FRAMES;
VkDescriptorPoolCreateInfo scenePoolInfo{};
scenePoolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
scenePoolInfo.maxSets = 1;
scenePoolInfo.maxSets = SCENE_HISTORY_FRAMES;
scenePoolInfo.poolSizeCount = static_cast<uint32_t>(scenePoolSizes.size());
scenePoolInfo.pPoolSizes = scenePoolSizes.data();
if (vkCreateDescriptorPool(device, &scenePoolInfo, nullptr, &sceneDescPool) != VK_SUCCESS) {
@ -267,6 +267,47 @@ void WaterRenderer::recreatePipelines() {
}
}
void WaterRenderer::setRefractionEnabled(bool enabled) {
if (refractionEnabled == enabled) return;
refractionEnabled = enabled;
// When turning off, clear scene history images to black so the shader
// detects "no data" and uses the non-refraction path.
if (!enabled && vkCtx) {
vkCtx->immediateSubmit([&](VkCommandBuffer cmd) {
for (uint32_t f = 0; f < SCENE_HISTORY_FRAMES; f++) {
auto& sh = sceneHistory[f];
if (!sh.colorImage) continue;
VkImageMemoryBarrier toTransfer{};
toTransfer.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
toTransfer.oldLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
toTransfer.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
toTransfer.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
toTransfer.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
toTransfer.image = sh.colorImage;
toTransfer.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
toTransfer.srcAccessMask = VK_ACCESS_SHADER_READ_BIT;
toTransfer.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 0, nullptr, 0, nullptr, 1, &toTransfer);
VkClearColorValue clearColor = {{0.0f, 0.0f, 0.0f, 0.0f}};
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
vkCmdClearColorImage(cmd, sh.colorImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &clearColor, 1, &range);
VkImageMemoryBarrier toRead = toTransfer;
toRead.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
toRead.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
toRead.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
toRead.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
0, 0, nullptr, 0, nullptr, 1, &toRead);
}
});
}
}
void WaterRenderer::shutdown() {
clear();
@ -304,13 +345,15 @@ VkDescriptorSet WaterRenderer::allocateMaterialSet() {
void WaterRenderer::destroySceneHistoryResources() {
if (!vkCtx) return;
VkDevice device = vkCtx->getDevice();
if (sceneColorView) { vkDestroyImageView(device, sceneColorView, nullptr); sceneColorView = VK_NULL_HANDLE; }
if (sceneDepthView) { vkDestroyImageView(device, sceneDepthView, nullptr); sceneDepthView = VK_NULL_HANDLE; }
if (sceneColorImage) { vmaDestroyImage(vkCtx->getAllocator(), sceneColorImage, sceneColorAlloc); sceneColorImage = VK_NULL_HANDLE; sceneColorAlloc = VK_NULL_HANDLE; }
if (sceneDepthImage) { vmaDestroyImage(vkCtx->getAllocator(), sceneDepthImage, sceneDepthAlloc); sceneDepthImage = VK_NULL_HANDLE; sceneDepthAlloc = VK_NULL_HANDLE; }
for (auto& sh : sceneHistory) {
if (sh.colorView) { vkDestroyImageView(device, sh.colorView, nullptr); sh.colorView = VK_NULL_HANDLE; }
if (sh.depthView) { vkDestroyImageView(device, sh.depthView, nullptr); sh.depthView = VK_NULL_HANDLE; }
if (sh.colorImage) { vmaDestroyImage(vkCtx->getAllocator(), sh.colorImage, sh.colorAlloc); sh.colorImage = VK_NULL_HANDLE; sh.colorAlloc = VK_NULL_HANDLE; }
if (sh.depthImage) { vmaDestroyImage(vkCtx->getAllocator(), sh.depthImage, sh.depthAlloc); sh.depthImage = VK_NULL_HANDLE; sh.depthAlloc = VK_NULL_HANDLE; }
sh.sceneSet = VK_NULL_HANDLE;
}
if (sceneColorSampler) { vkDestroySampler(device, sceneColorSampler, nullptr); sceneColorSampler = VK_NULL_HANDLE; }
if (sceneDepthSampler) { vkDestroySampler(device, sceneDepthSampler, nullptr); sceneDepthSampler = VK_NULL_HANDLE; }
sceneSet = VK_NULL_HANDLE;
sceneHistoryExtent = {0, 0};
sceneHistoryReady = false;
}
@ -323,54 +366,7 @@ void WaterRenderer::createSceneHistoryResources(VkExtent2D extent, VkFormat colo
vkResetDescriptorPool(device, sceneDescPool, 0);
sceneHistoryExtent = extent;
VkImageCreateInfo colorImgInfo{};
colorImgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
colorImgInfo.imageType = VK_IMAGE_TYPE_2D;
colorImgInfo.format = colorFormat;
colorImgInfo.extent = {extent.width, extent.height, 1};
colorImgInfo.mipLevels = 1;
colorImgInfo.arrayLayers = 1;
colorImgInfo.samples = VK_SAMPLE_COUNT_1_BIT;
colorImgInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
colorImgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
colorImgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
VmaAllocationCreateInfo allocCI{};
allocCI.usage = VMA_MEMORY_USAGE_GPU_ONLY;
if (vmaCreateImage(vkCtx->getAllocator(), &colorImgInfo, &allocCI, &sceneColorImage, &sceneColorAlloc, nullptr) != VK_SUCCESS) {
LOG_ERROR("WaterRenderer: failed to create scene color history image");
return;
}
VkImageCreateInfo depthImgInfo = colorImgInfo;
depthImgInfo.format = depthFormat;
if (vmaCreateImage(vkCtx->getAllocator(), &depthImgInfo, &allocCI, &sceneDepthImage, &sceneDepthAlloc, nullptr) != VK_SUCCESS) {
LOG_ERROR("WaterRenderer: failed to create scene depth history image");
return;
}
VkImageViewCreateInfo colorViewInfo{};
colorViewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
colorViewInfo.image = sceneColorImage;
colorViewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
colorViewInfo.format = colorFormat;
colorViewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
colorViewInfo.subresourceRange.levelCount = 1;
colorViewInfo.subresourceRange.layerCount = 1;
if (vkCreateImageView(device, &colorViewInfo, nullptr, &sceneColorView) != VK_SUCCESS) {
LOG_ERROR("WaterRenderer: failed to create scene color history view");
return;
}
VkImageViewCreateInfo depthViewInfo = colorViewInfo;
depthViewInfo.image = sceneDepthImage;
depthViewInfo.format = depthFormat;
depthViewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT;
if (vkCreateImageView(device, &depthViewInfo, nullptr, &sceneDepthView) != VK_SUCCESS) {
LOG_ERROR("WaterRenderer: failed to create scene depth history view");
return;
}
// Create shared samplers
VkSamplerCreateInfo sampCI{};
sampCI.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
sampCI.magFilter = VK_FILTER_LINEAR;
@ -389,99 +385,155 @@ void WaterRenderer::createSceneHistoryResources(VkExtent2D extent, VkFormat colo
return;
}
VkDescriptorSetAllocateInfo ai{};
ai.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
ai.descriptorPool = sceneDescPool;
ai.descriptorSetCount = 1;
ai.pSetLayouts = &sceneSetLayout;
if (vkAllocateDescriptorSets(device, &ai, &sceneSet) != VK_SUCCESS) {
LOG_ERROR("WaterRenderer: failed to allocate scene descriptor set");
sceneSet = VK_NULL_HANDLE;
return;
VkImageCreateInfo colorImgInfo{};
colorImgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
colorImgInfo.imageType = VK_IMAGE_TYPE_2D;
colorImgInfo.format = colorFormat;
colorImgInfo.extent = {extent.width, extent.height, 1};
colorImgInfo.mipLevels = 1;
colorImgInfo.arrayLayers = 1;
colorImgInfo.samples = VK_SAMPLE_COUNT_1_BIT;
colorImgInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
colorImgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
colorImgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
VkImageCreateInfo depthImgInfo = colorImgInfo;
depthImgInfo.format = depthFormat;
VmaAllocationCreateInfo allocCI{};
allocCI.usage = VMA_MEMORY_USAGE_GPU_ONLY;
// Create per-frame images, views, and descriptor sets
for (uint32_t f = 0; f < SCENE_HISTORY_FRAMES; f++) {
auto& sh = sceneHistory[f];
if (vmaCreateImage(vkCtx->getAllocator(), &colorImgInfo, &allocCI, &sh.colorImage, &sh.colorAlloc, nullptr) != VK_SUCCESS) {
LOG_ERROR("WaterRenderer: failed to create scene color history image [", f, "]");
return;
}
if (vmaCreateImage(vkCtx->getAllocator(), &depthImgInfo, &allocCI, &sh.depthImage, &sh.depthAlloc, nullptr) != VK_SUCCESS) {
LOG_ERROR("WaterRenderer: failed to create scene depth history image [", f, "]");
return;
}
VkImageViewCreateInfo colorViewInfo{};
colorViewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
colorViewInfo.image = sh.colorImage;
colorViewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
colorViewInfo.format = colorFormat;
colorViewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
colorViewInfo.subresourceRange.levelCount = 1;
colorViewInfo.subresourceRange.layerCount = 1;
if (vkCreateImageView(device, &colorViewInfo, nullptr, &sh.colorView) != VK_SUCCESS) {
LOG_ERROR("WaterRenderer: failed to create scene color history view [", f, "]");
return;
}
VkImageViewCreateInfo depthViewInfo = colorViewInfo;
depthViewInfo.image = sh.depthImage;
depthViewInfo.format = depthFormat;
depthViewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT;
if (vkCreateImageView(device, &depthViewInfo, nullptr, &sh.depthView) != VK_SUCCESS) {
LOG_ERROR("WaterRenderer: failed to create scene depth history view [", f, "]");
return;
}
// Allocate descriptor set for this frame
VkDescriptorSetAllocateInfo ai{};
ai.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
ai.descriptorPool = sceneDescPool;
ai.descriptorSetCount = 1;
ai.pSetLayouts = &sceneSetLayout;
if (vkAllocateDescriptorSets(device, &ai, &sh.sceneSet) != VK_SUCCESS) {
LOG_ERROR("WaterRenderer: failed to allocate scene descriptor set [", f, "]");
sh.sceneSet = VK_NULL_HANDLE;
return;
}
VkDescriptorImageInfo colorInfo{};
colorInfo.sampler = sceneColorSampler;
colorInfo.imageView = sh.colorView;
colorInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkDescriptorImageInfo depthInfo{};
depthInfo.sampler = sceneDepthSampler;
depthInfo.imageView = sh.depthView;
depthInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkDescriptorImageInfo reflColorInfo{};
reflColorInfo.sampler = sceneColorSampler;
reflColorInfo.imageView = reflectionColorView ? reflectionColorView : sh.colorView;
reflColorInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkDescriptorBufferInfo reflUBOInfo{};
reflUBOInfo.buffer = reflectionUBO;
reflUBOInfo.offset = 0;
reflUBOInfo.range = sizeof(ReflectionUBOData);
std::vector<VkWriteDescriptorSet> writes;
VkWriteDescriptorSet w0{};
w0.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
w0.dstSet = sh.sceneSet;
w0.dstBinding = 0;
w0.descriptorCount = 1;
w0.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
w0.pImageInfo = &colorInfo;
writes.push_back(w0);
VkWriteDescriptorSet w1{};
w1.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
w1.dstSet = sh.sceneSet;
w1.dstBinding = 1;
w1.descriptorCount = 1;
w1.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
w1.pImageInfo = &depthInfo;
writes.push_back(w1);
VkWriteDescriptorSet w2{};
w2.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
w2.dstSet = sh.sceneSet;
w2.dstBinding = 2;
w2.descriptorCount = 1;
w2.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
w2.pImageInfo = &reflColorInfo;
writes.push_back(w2);
if (reflectionUBO) {
VkWriteDescriptorSet w3{};
w3.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
w3.dstSet = sh.sceneSet;
w3.dstBinding = 3;
w3.descriptorCount = 1;
w3.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
w3.pBufferInfo = &reflUBOInfo;
writes.push_back(w3);
}
vkUpdateDescriptorSets(device, static_cast<uint32_t>(writes.size()), writes.data(), 0, nullptr);
}
VkDescriptorImageInfo colorInfo{};
colorInfo.sampler = sceneColorSampler;
colorInfo.imageView = sceneColorView;
colorInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkDescriptorImageInfo depthInfo{};
depthInfo.sampler = sceneDepthSampler;
depthInfo.imageView = sceneDepthView;
depthInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
// Reflection color texture (binding 2) — use scene color as placeholder until reflection is created
VkDescriptorImageInfo reflColorInfo{};
reflColorInfo.sampler = sceneColorSampler;
reflColorInfo.imageView = reflectionColorView ? reflectionColorView : sceneColorView;
reflColorInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
// Reflection UBO (binding 3)
VkDescriptorBufferInfo reflUBOInfo{};
reflUBOInfo.buffer = reflectionUBO;
reflUBOInfo.offset = 0;
reflUBOInfo.range = sizeof(ReflectionUBOData);
// Write bindings 0,1 always; write 2,3 only if reflection resources exist
std::vector<VkWriteDescriptorSet> writes;
VkWriteDescriptorSet w0{};
w0.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
w0.dstSet = sceneSet;
w0.dstBinding = 0;
w0.descriptorCount = 1;
w0.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
w0.pImageInfo = &colorInfo;
writes.push_back(w0);
VkWriteDescriptorSet w1{};
w1.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
w1.dstSet = sceneSet;
w1.dstBinding = 1;
w1.descriptorCount = 1;
w1.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
w1.pImageInfo = &depthInfo;
writes.push_back(w1);
VkWriteDescriptorSet w2{};
w2.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
w2.dstSet = sceneSet;
w2.dstBinding = 2;
w2.descriptorCount = 1;
w2.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
w2.pImageInfo = &reflColorInfo;
writes.push_back(w2);
if (reflectionUBO) {
VkWriteDescriptorSet w3{};
w3.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
w3.dstSet = sceneSet;
w3.dstBinding = 3;
w3.descriptorCount = 1;
w3.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
w3.pBufferInfo = &reflUBOInfo;
writes.push_back(w3);
}
vkUpdateDescriptorSets(device, static_cast<uint32_t>(writes.size()), writes.data(), 0, nullptr);
// Initialize history images to shader-read layout so first frame samples are defined.
// Initialize all per-frame history images to shader-read layout
vkCtx->immediateSubmit([&](VkCommandBuffer cmd) {
VkImageMemoryBarrier barriers[2]{};
barriers[0].sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
barriers[0].oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
barriers[0].newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
barriers[0].srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barriers[0].dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barriers[0].image = sceneColorImage;
barriers[0].subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
barriers[0].dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
barriers[1] = barriers[0];
barriers[1].image = sceneDepthImage;
barriers[1].subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT;
std::vector<VkImageMemoryBarrier> barriers;
for (uint32_t f = 0; f < SCENE_HISTORY_FRAMES; f++) {
VkImageMemoryBarrier b{};
b.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
b.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
b.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
b.image = sceneHistory[f].colorImage;
b.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
barriers.push_back(b);
b.image = sceneHistory[f].depthImage;
b.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT;
barriers.push_back(b);
}
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
0, 0, nullptr, 0, nullptr, 2, barriers);
0, 0, nullptr, 0, nullptr, static_cast<uint32_t>(barriers.size()), barriers.data());
});
}
@ -492,9 +544,14 @@ void WaterRenderer::updateMaterialUBO(WaterSurface& surface) {
// WMO liquid material override
if (surface.wmoId != 0) {
const uint8_t basicType = (surface.liquidType == 0) ? 0 : ((surface.liquidType - 1) % 4);
if (basicType == 2 || basicType == 3) {
color = glm::vec4(0.2f, 0.4f, 0.6f, 1.0f);
alpha = 0.45f;
if (basicType == 2) {
// Magma — bright orange-red, opaque
color = glm::vec4(1.0f, 0.35f, 0.05f, 1.0f);
alpha = 0.95f;
} else if (basicType == 3) {
// Slime — green, semi-opaque
color = glm::vec4(0.2f, 0.6f, 0.1f, 1.0f);
alpha = 0.85f;
}
}
@ -632,12 +689,12 @@ void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool ap
layer.minHeight
);
surface.origin = glm::vec3(
surface.position.x - (static_cast<float>(layer.y) * TILE_SIZE),
surface.position.y - (static_cast<float>(layer.x) * TILE_SIZE),
surface.position.x - (static_cast<float>(layer.x) * TILE_SIZE),
surface.position.y - (static_cast<float>(layer.y) * TILE_SIZE),
layer.minHeight
);
surface.stepX = glm::vec3(0.0f, -TILE_SIZE, 0.0f);
surface.stepY = glm::vec3(-TILE_SIZE, 0.0f, 0.0f);
surface.stepX = glm::vec3(-TILE_SIZE, 0.0f, 0.0f);
surface.stepY = glm::vec3(0.0f, -TILE_SIZE, 0.0f);
surface.minHeight = layer.minHeight;
surface.maxHeight = layer.maxHeight;
@ -698,8 +755,8 @@ void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool ap
// Origin = chunk(0,0) position (NW corner of tile)
surface.origin = glm::vec3(chunk00.position[0], chunk00.position[1], groupHeight);
surface.position = surface.origin;
surface.stepX = glm::vec3(0.0f, -TILE_SIZE, 0.0f);
surface.stepY = glm::vec3(-TILE_SIZE, 0.0f, 0.0f);
surface.stepX = glm::vec3(-TILE_SIZE, 0.0f, 0.0f);
surface.stepY = glm::vec3(0.0f, -TILE_SIZE, 0.0f);
surface.minHeight = groupHeight;
surface.maxHeight = groupHeight;
@ -883,7 +940,7 @@ void WaterRenderer::loadFromWMO([[maybe_unused]] const pipeline::WMOLiquid& liqu
surface.origin.z = adjustedZ;
surface.position.z = adjustedZ;
if (surface.origin.z > 300.0f || surface.origin.z < -100.0f) return;
if (surface.origin.z > 2000.0f || surface.origin.z < -500.0f) return;
// Build tile mask from MLIQ flags and per-vertex heights
size_t tileCount = static_cast<size_t>(surface.width) * static_cast<size_t>(surface.height);
@ -986,7 +1043,7 @@ void WaterRenderer::clear() {
// ==============================================================
void WaterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
const Camera& /*camera*/, float /*time*/, bool use1x) {
const Camera& /*camera*/, float /*time*/, bool use1x, uint32_t frameIndex) {
VkPipeline pipeline = (use1x && water1xPipeline) ? water1xPipeline : waterPipeline;
if (!renderingEnabled || surfaces.empty() || !pipeline) {
if (renderDiagCounter_++ % 300 == 0 && !surfaces.empty()) {
@ -997,7 +1054,9 @@ void WaterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
}
return;
}
if (!sceneSet) {
uint32_t fi = frameIndex % SCENE_HISTORY_FRAMES;
VkDescriptorSet activeSceneSet = sceneHistory[fi].sceneSet;
if (!activeSceneSet) {
if (renderDiagCounter_++ % 300 == 0) {
LOG_WARNING("Water: render skipped — sceneSet is null, surfaces=", surfaces.size());
}
@ -1009,7 +1068,7 @@ void WaterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout,
0, 1, &perFrameSet, 0, nullptr);
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout,
2, 1, &sceneSet, 0, nullptr);
2, 1, &activeSceneSet, 0, nullptr);
for (const auto& surface : surfaces) {
if (surface.vertexBuffer == VK_NULL_HANDLE || surface.indexCount == 0) continue;
@ -1050,8 +1109,11 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd,
VkImage srcColorImage,
VkImage srcDepthImage,
VkExtent2D srcExtent,
bool srcDepthIsMsaa) {
if (!vkCtx || !cmd || !sceneColorImage || !sceneDepthImage || srcExtent.width == 0 || srcExtent.height == 0) {
bool srcDepthIsMsaa,
uint32_t frameIndex) {
uint32_t fi = frameIndex % SCENE_HISTORY_FRAMES;
auto& sh = sceneHistory[fi];
if (!vkCtx || !cmd || !sh.colorImage || !sh.depthImage || srcExtent.width == 0 || srcExtent.height == 0) {
return;
}
@ -1091,7 +1153,7 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
0, VK_ACCESS_TRANSFER_READ_BIT,
VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
barrier2(sceneColorImage, VK_IMAGE_ASPECT_COLOR_BIT,
barrier2(sh.colorImage, VK_IMAGE_ASPECT_COLOR_BIT,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
@ -1101,9 +1163,9 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd,
colorCopy.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
colorCopy.extent = {copyExtent.width, copyExtent.height, 1};
vkCmdCopyImage(cmd, srcColorImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
sceneColorImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &colorCopy);
sh.colorImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &colorCopy);
barrier2(sceneColorImage, VK_IMAGE_ASPECT_COLOR_BIT,
barrier2(sh.colorImage, VK_IMAGE_ASPECT_COLOR_BIT,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT,
VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT);
@ -1118,7 +1180,7 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd,
VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT,
VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
barrier2(sceneDepthImage, VK_IMAGE_ASPECT_DEPTH_BIT,
barrier2(sh.depthImage, VK_IMAGE_ASPECT_DEPTH_BIT,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
@ -1128,9 +1190,9 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd,
depthCopy.dstSubresource = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 0, 1};
depthCopy.extent = {copyExtent.width, copyExtent.height, 1};
vkCmdCopyImage(cmd, srcDepthImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
sceneDepthImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &depthCopy);
sh.depthImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &depthCopy);
barrier2(sceneDepthImage, VK_IMAGE_ASPECT_DEPTH_BIT,
barrier2(sh.depthImage, VK_IMAGE_ASPECT_DEPTH_BIT,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT,
VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT);
@ -1543,11 +1605,11 @@ bool WaterRenderer::isWmoWaterAt(float glX, float glY) const {
glm::vec4 WaterRenderer::getLiquidColor(uint16_t liquidType) const {
uint8_t basicType = (liquidType == 0) ? 0 : ((liquidType - 1) % 4);
switch (basicType) {
case 0: return glm::vec4(0.12f, 0.32f, 0.48f, 1.0f); // inland: blue-green
case 1: return glm::vec4(0.04f, 0.14f, 0.30f, 1.0f); // ocean: deep blue
case 0: return glm::vec4(0.10f, 0.28f, 0.55f, 1.0f); // inland: richer blue
case 1: return glm::vec4(0.04f, 0.16f, 0.38f, 1.0f); // ocean: deep blue
case 2: return glm::vec4(0.9f, 0.3f, 0.05f, 1.0f); // magma
case 3: return glm::vec4(0.2f, 0.6f, 0.1f, 1.0f); // slime
default: return glm::vec4(0.12f, 0.32f, 0.48f, 1.0f);
default: return glm::vec4(0.10f, 0.28f, 0.55f, 1.0f);
}
}
@ -1815,21 +1877,28 @@ void WaterRenderer::endReflectionPass(VkCommandBuffer cmd) {
vkCmdEndRenderPass(cmd);
reflectionColorLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
// Update scene descriptor set with the freshly rendered reflection texture
if (sceneSet && reflectionColorView && reflectionSampler) {
// Update all per-frame scene descriptor sets with the freshly rendered reflection texture
if (reflectionColorView && reflectionSampler) {
VkDescriptorImageInfo reflInfo{};
reflInfo.sampler = reflectionSampler;
reflInfo.imageView = reflectionColorView;
reflInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkWriteDescriptorSet write{};
write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
write.dstSet = sceneSet;
write.dstBinding = 2;
write.descriptorCount = 1;
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
write.pImageInfo = &reflInfo;
vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &write, 0, nullptr);
std::vector<VkWriteDescriptorSet> writes;
for (uint32_t f = 0; f < SCENE_HISTORY_FRAMES; f++) {
if (!sceneHistory[f].sceneSet) continue;
VkWriteDescriptorSet write{};
write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
write.dstSet = sceneHistory[f].sceneSet;
write.dstBinding = 2;
write.descriptorCount = 1;
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
write.pImageInfo = &reflInfo;
writes.push_back(write);
}
if (!writes.empty()) {
vkUpdateDescriptorSets(vkCtx->getDevice(), static_cast<uint32_t>(writes.size()), writes.data(), 0, nullptr);
}
}
}

View file

@ -593,13 +593,22 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
// Detect window/glass materials by texture name.
// Flag 0x10 (F_SIDN) marks night-glow materials (windows AND lamps),
// so we additionally check for "window" in the texture path to
// so we additionally check for "window" or "glass" in the texture path to
// distinguish actual glass from lamp post geometry.
bool isWindow = false;
bool isLava = false;
if (batch.materialId < modelData.materialTextureIndices.size()) {
uint32_t ti = modelData.materialTextureIndices[batch.materialId];
if (ti < modelData.textureNames.size()) {
isWindow = (modelData.textureNames[ti].find("window") != std::string::npos);
const auto& texName = modelData.textureNames[ti];
// Case-insensitive search for material types
std::string texNameLower = texName;
std::transform(texNameLower.begin(), texNameLower.end(), texNameLower.begin(), ::tolower);
isWindow = (texNameLower.find("window") != std::string::npos ||
texNameLower.find("glass") != std::string::npos);
isLava = (texNameLower.find("lava") != std::string::npos ||
texNameLower.find("molten") != std::string::npos ||
texNameLower.find("magma") != std::string::npos);
}
}
@ -612,6 +621,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
mb.unlit = unlit;
mb.isTransparent = (blendMode >= 2);
mb.isWindow = isWindow;
mb.isLava = isLava;
// Look up normal/height map from texture cache
if (hasTexture && tex != whiteTexture_.get()) {
for (const auto& [cacheKey, cacheEntry] : textureCache) {
@ -651,7 +661,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
matData.unlit = mb.unlit ? 1 : 0;
matData.isInterior = isInterior ? 1 : 0;
matData.specularIntensity = 0.5f;
matData.isWindow = mb.isWindow ? 1 : 0;
matData.isWindow = mb.isWindow ? (wmoOnlyMap_ ? 2 : 1) : 0;
matData.enableNormalMap = normalMappingEnabled_ ? 1 : 0;
matData.enablePOM = pomEnabled_ ? 1 : 0;
matData.pomScale = 0.012f;
@ -661,6 +671,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
}
matData.heightMapVariance = mb.heightMapVariance;
matData.normalMapStrength = normalMapStrength_;
matData.isLava = mb.isLava ? 1 : 0;
if (matBuf.info.pMappedData) {
memcpy(matBuf.info.pMappedData, &matData, sizeof(matData));
}
@ -782,6 +793,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
doodadTemplate.m2Path = m2Path;
doodadTemplate.localTransform = localTransform;
modelData.doodadTemplates.push_back(doodadTemplate);
}
if (!modelData.doodadTemplates.empty()) {
@ -1724,11 +1736,18 @@ void WMORenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceM
struct ShadowPush { glm::mat4 lightSpaceMatrix; glm::mat4 model; };
const float shadowRadiusSq = shadowRadius * shadowRadius;
// WMO shadow cull uses the ortho half-extent (shadow map coverage) rather than
// the proximity radius so that distant buildings whose shadows reach the player
// are still rendered into the shadow map.
const float wmoCullRadius = std::max(shadowRadius, 180.0f);
const float wmoCullRadiusSq = wmoCullRadius * wmoCullRadius;
for (const auto& instance : instances) {
// Distance cull against shadow frustum
glm::vec3 diff = instance.position - shadowCenter;
if (glm::dot(diff, diff) > shadowRadiusSq) continue;
// Distance cull using world bounding box — WMO origins can be far from
// their geometry, so point-based culling misses large buildings.
glm::vec3 closest = glm::clamp(shadowCenter, instance.worldBoundsMin, instance.worldBoundsMax);
glm::vec3 diff = closest - shadowCenter;
if (glm::dot(diff, diff) > wmoCullRadiusSq) continue;
auto modelIt = loadedModels.find(instance.modelId);
if (modelIt == loadedModels.end()) continue;
const ModelData& model = modelIt->second;
@ -1737,7 +1756,8 @@ void WMORenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceM
vkCmdPushConstants(cmd, shadowPipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT,
0, 128, &push);
for (const auto& group : model.groups) {
for (size_t gi = 0; gi < model.groups.size(); ++gi) {
const auto& group = model.groups[gi];
if (group.vertexBuffer == VK_NULL_HANDLE || group.indexBuffer == VK_NULL_HANDLE) continue;
// Skip antiportal geometry
@ -1746,13 +1766,18 @@ void WMORenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceM
// Skip LOD groups in shadow pass (they overlap real geometry)
if (group.isLOD) continue;
// Per-group AABB cull against shadow frustum
if (gi < instance.worldGroupBounds.size()) {
const auto& [gMin, gMax] = instance.worldGroupBounds[gi];
glm::vec3 gClosest = glm::clamp(shadowCenter, gMin, gMax);
glm::vec3 gDiff = gClosest - shadowCenter;
if (glm::dot(gDiff, gDiff) > wmoCullRadiusSq) continue;
}
VkDeviceSize offset = 0;
vkCmdBindVertexBuffers(cmd, 0, 1, &group.vertexBuffer, &offset);
vkCmdBindIndexBuffer(cmd, group.indexBuffer, 0, VK_INDEX_TYPE_UINT16);
// Draw all batches in shadow pass.
// WMO transparency classification is not reliable enough for caster
// selection here and was dropping major world casters.
for (const auto& mb : group.mergedBatches) {
for (const auto& dr : mb.draws) {
vkCmdDrawIndexed(cmd, dr.indexCount, 1, dr.firstIndex, 0, 0);
@ -3082,7 +3107,7 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
float rangeMinY = std::min(localFrom.y, localTo.y) - PLAYER_RADIUS - 1.5f;
float rangeMaxX = std::max(localFrom.x, localTo.x) + PLAYER_RADIUS + 1.5f;
float rangeMaxY = std::max(localFrom.y, localTo.y) + PLAYER_RADIUS + 1.5f;
group.getWallTrianglesInRange(rangeMinX, rangeMinY, rangeMaxX, rangeMaxY, triScratch_);
group.getTrianglesInRange(rangeMinX, rangeMinY, rangeMaxX, rangeMaxY, triScratch_);
for (uint32_t triStart : triScratch_) {
// Use pre-computed Z bounds for fast vertical reject
@ -3100,17 +3125,18 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
if (triHeight < 1.0f && tb.maxZ <= localFeetZ + 1.2f) continue;
// Use MOPY flags to filter wall collision.
// Only RENDERED triangles (flag 0x20) with collision intent (0x01)
// should block the player. Skip invisible collision hulls (0x08/0x48)
// and non-collidable render-only geometry.
// Collidable triangles (flag 0x01) block the player — including
// invisible collision walls (0x01 without 0x20) used in tunnels.
// Skip detail/decorative geometry (0x04) and render-only surfaces.
uint32_t triIdx = triStart / 3;
if (!group.triMopyFlags.empty() && triIdx < group.triMopyFlags.size()) {
uint8_t mopy = group.triMopyFlags[triIdx];
// Must be rendered (0x20) AND have base collision flag (0x01)
bool rendered = (mopy & 0x20) != 0;
bool collidable = (mopy & 0x01) != 0;
if (mopy != 0 && !(rendered && collidable)) {
continue;
if (mopy != 0) {
bool collidable = (mopy & 0x01) != 0;
bool detail = (mopy & 0x04) != 0;
if (!collidable || detail) {
continue;
}
}
}
@ -3136,13 +3162,13 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
glm::vec3 hitPoint = localFrom + (localTo - localFrom) * tHit;
glm::vec3 hitClosest = closestPointOnTriangle(hitPoint, v0, v1, v2);
float hitErrSq = glm::dot(hitClosest - hitPoint, hitClosest - hitPoint);
if (hitErrSq <= 0.15f * 0.15f) {
if (hitErrSq <= 0.25f * 0.25f) {
float side = fromDist > 0.0f ? 1.0f : -1.0f;
glm::vec3 safeLocal = hitPoint + normal * side * (PLAYER_RADIUS + 0.05f);
glm::vec3 pushLocal(safeLocal.x - localTo.x, safeLocal.y - localTo.y, 0.0f);
// Cap swept pushback so walls don't shove the player violently
float pushLen = glm::length(glm::vec2(pushLocal.x, pushLocal.y));
const float MAX_SWEPT_PUSH = 0.15f;
const float MAX_SWEPT_PUSH = insideWMO ? 0.45f : 0.25f;
if (pushLen > MAX_SWEPT_PUSH) {
float scale = MAX_SWEPT_PUSH / pushLen;
pushLocal.x *= scale;
@ -3172,7 +3198,7 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
const float SKIN = 0.005f; // small separation so we don't re-collide immediately
// Stronger push when inside WMO for more responsive indoor collision
const float MAX_PUSH = insideWMO ? 0.12f : 0.08f;
const float MAX_PUSH = insideWMO ? 0.35f : 0.15f;
float penetration = (PLAYER_RADIUS - horizDist);
float pushDist = glm::clamp(penetration + SKIN, 0.0f, MAX_PUSH);
glm::vec2 pushDir2;

View file

@ -288,6 +288,15 @@ void GameScreen::render(game::GameHandler& gameHandler) {
msaaSettingsApplied_ = true;
}
// Apply saved water refraction setting once when renderer is available
if (!waterRefractionApplied_) {
auto* renderer = core::Application::getInstance().getRenderer();
if (renderer) {
renderer->setWaterRefractionEnabled(pendingWaterRefraction);
waterRefractionApplied_ = true;
}
}
// Apply saved normal mapping / POM settings once when WMO renderer is available
if (!normalMapSettingsApplied_) {
auto* renderer = core::Application::getInstance().getRenderer();
@ -1358,16 +1367,16 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
}
// Slash key: focus chat input
if (!io.WantCaptureKeyboard && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) {
// Slash key: focus chat input — always works unless already typing in chat
if (!chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) {
refocusChatInput = true;
chatInputBuffer[0] = '/';
chatInputBuffer[1] = '\0';
chatInputMoveCursorToEnd = true;
}
// Enter key: focus chat input (empty)
if (!io.WantCaptureKeyboard && input.isKeyJustPressed(SDL_SCANCODE_RETURN)) {
// Enter key: focus chat input (empty) — always works unless already typing
if (!chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_RETURN)) {
refocusChatInput = true;
}
@ -6171,6 +6180,7 @@ void GameScreen::renderSettingsWindow() {
pendingVsync = window->isVsyncEnabled();
if (renderer) {
renderer->setShadowsEnabled(pendingShadows);
renderer->setShadowDistance(pendingShadowDistance);
// Read non-volume settings from actual state (volumes come from saved settings)
if (auto* cameraController = renderer->getCameraController()) {
pendingMouseSensitivity = cameraController->getMouseSensitivity();
@ -6237,6 +6247,18 @@ void GameScreen::renderSettingsWindow() {
if (renderer) renderer->setShadowsEnabled(pendingShadows);
saveSettings();
}
if (pendingShadows) {
ImGui::SameLine();
ImGui::SetNextItemWidth(150.0f);
if (ImGui::SliderFloat("Distance##shadow", &pendingShadowDistance, 40.0f, 200.0f, "%.0f")) {
if (renderer) renderer->setShadowDistance(pendingShadowDistance);
saveSettings();
}
}
if (ImGui::Checkbox("Water Refraction", &pendingWaterRefraction)) {
if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction);
saveSettings();
}
{
const char* aaLabels[] = { "Off", "2x MSAA", "4x MSAA", "8x MSAA" };
if (ImGui::Combo("Anti-Aliasing", &pendingAntiAliasing, aaLabels, 4)) {
@ -6326,6 +6348,7 @@ void GameScreen::renderSettingsWindow() {
pendingFullscreen = kDefaultFullscreen;
pendingVsync = kDefaultVsync;
pendingShadows = kDefaultShadows;
pendingShadowDistance = 72.0f;
pendingGroundClutterDensity = kDefaultGroundClutterDensity;
pendingAntiAliasing = 0;
pendingNormalMapping = true;
@ -6336,7 +6359,12 @@ void GameScreen::renderSettingsWindow() {
window->setFullscreen(pendingFullscreen);
window->setVsync(pendingVsync);
window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]);
if (renderer) renderer->setShadowsEnabled(pendingShadows);
pendingWaterRefraction = false;
if (renderer) {
renderer->setShadowsEnabled(pendingShadows);
renderer->setShadowDistance(pendingShadowDistance);
}
if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction);
if (renderer) renderer->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT);
if (renderer) {
if (auto* tm = renderer->getTerrainManager()) {
@ -7349,6 +7377,8 @@ void GameScreen::saveSettings() {
out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n";
out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n";
out << "shadows=" << (pendingShadows ? 1 : 0) << "\n";
out << "shadow_distance=" << pendingShadowDistance << "\n";
out << "water_refraction=" << (pendingWaterRefraction ? 1 : 0) << "\n";
out << "antialiasing=" << pendingAntiAliasing << "\n";
out << "normal_mapping=" << (pendingNormalMapping ? 1 : 0) << "\n";
out << "normal_map_strength=" << pendingNormalMapStrength << "\n";
@ -7433,6 +7463,8 @@ void GameScreen::loadSettings() {
else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0);
else if (key == "ground_clutter_density") pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150);
else if (key == "shadows") pendingShadows = (std::stoi(val) != 0);
else if (key == "shadow_distance") pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 200.0f);
else if (key == "water_refraction") pendingWaterRefraction = (std::stoi(val) != 0);
else if (key == "antialiasing") pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3);
else if (key == "normal_mapping") pendingNormalMapping = (std::stoi(val) != 0);
else if (key == "normal_map_strength") pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f);