Merge phase-2b/atlas-neighbors-cameras-events: Phase 2b
Phase 2b MVP complete. 9 commits implementing: - Vertex struct gains TerrainLayer (uint, location 3 VertexAttribIPointer) and LandblockMesh.Build takes a TerrainTextureType → atlas layer map - TerrainAtlas builds GL_TEXTURE_2D_ARRAY from Region.TerrainInfo. LandSurfaces.TexMerge.TerrainDesc, one layer per referenced TerrainTextureType - Terrain shader rewritten to sample sampler2DArray with flat uint layer and per-landblock uModel translation - TerrainRenderer grows AddLandblock(mesh, worldOrigin) for multi-landblock drawing, drops the ctor mesh param in favor of the new add pattern - GameWindow replaces single-landblock load with WorldView.Load 3x3 grid; entity positions are translated by their source landblock's world offset - ICamera interface extracted, OrbitCamera refactored to implement - FlyCamera with WASD + raw cursor mouse look, pitch clamp, horizontal-plane movement independent of pitch - CameraController with F toggle, Escape contextual (fly→orbit releases cursor; orbit→Escape closes window), cursor capture via CursorMode.Raw - IGameState + IEvents + WorldEntitySnapshot added to Plugin.Abstractions - WorldEvents implements replay-on-subscribe so plugins that subscribe after the world is loaded see every already-spawned entity exactly once before returning from += - WorldGameState exposes the entity snapshot list; AppPluginHost constructor gains state + events parameters - Program.cs and GameWindow thread worldGameState + worldEvents through the startup; OnLoad calls FireEntitySpawned for each hydrated entity - SmokePlugin subscribes in Enable, logs replay count at subscribe time (0 because it subscribes before world load) and the total seen count at Disable time (via live events during hydration) 6 new xUnit tests (43 → 48): 1 terrain layer mapping regression + 5 WorldEvents replay/live/unsubscribe/exception-safety tests. Smoke verified against real dats: 33 terrain atlas layers at 512x512, 9 landblocks loaded in 3x3 grid, 239 entities hydrated across all landblocks, build clean, no exceptions, all plugin lifecycle logs visible including the new `sees 0 entities (replay count at subscribe)` line. Phase 2 (both 2a and 2b) is done. Next phase is visual verification by the user and then Phase 3 planning (animated entities, doors, terrain texture blending at cell boundaries, proper lighting, etc).
This commit is contained in:
commit
fe0bfb075b
25 changed files with 774 additions and 133 deletions
|
|
@ -4,6 +4,14 @@ namespace AcDream.App.Plugins;
|
|||
|
||||
public sealed class AppPluginHost : IPluginHost
|
||||
{
|
||||
public AppPluginHost(IPluginLogger log) => Log = log;
|
||||
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events)
|
||||
{
|
||||
Log = log;
|
||||
State = state;
|
||||
Events = events;
|
||||
}
|
||||
|
||||
public IPluginLogger Log { get; }
|
||||
public IGameState State { get; }
|
||||
public IEvents Events { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ if (string.IsNullOrWhiteSpace(datDir))
|
|||
return 2;
|
||||
}
|
||||
|
||||
var host = new AppPluginHost(new SerilogAdapter(Log.Logger));
|
||||
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
|
||||
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
|
||||
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents);
|
||||
|
||||
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
||||
|
|
@ -48,7 +50,7 @@ try
|
|||
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
||||
}
|
||||
|
||||
using var window = new GameWindow(datDir);
|
||||
using var window = new GameWindow(datDir, worldGameState, worldEvents);
|
||||
window.Run();
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
31
src/AcDream.App/Rendering/CameraController.cs
Normal file
31
src/AcDream.App/Rendering/CameraController.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// src/AcDream.App/Rendering/CameraController.cs
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
public sealed class CameraController
|
||||
{
|
||||
public OrbitCamera Orbit { get; }
|
||||
public FlyCamera Fly { get; }
|
||||
public ICamera Active { get; private set; }
|
||||
public bool IsFlyMode => Active == Fly;
|
||||
|
||||
public event Action<bool>? ModeChanged;
|
||||
|
||||
public CameraController(OrbitCamera orbit, FlyCamera fly)
|
||||
{
|
||||
Orbit = orbit;
|
||||
Fly = fly;
|
||||
Active = orbit;
|
||||
}
|
||||
|
||||
public void ToggleFly()
|
||||
{
|
||||
Active = IsFlyMode ? (ICamera)Orbit : Fly;
|
||||
ModeChanged?.Invoke(IsFlyMode);
|
||||
}
|
||||
|
||||
public void SetAspect(float aspect)
|
||||
{
|
||||
Orbit.Aspect = aspect;
|
||||
Fly.Aspect = aspect;
|
||||
}
|
||||
}
|
||||
71
src/AcDream.App/Rendering/FlyCamera.cs
Normal file
71
src/AcDream.App/Rendering/FlyCamera.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// src/AcDream.App/Rendering/FlyCamera.cs
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
public sealed class FlyCamera : ICamera
|
||||
{
|
||||
public Vector3 Position { get; set; } = new(96, 96, 150);
|
||||
public float Yaw { get; set; } = MathF.PI / 2f; // facing +Y
|
||||
public float Pitch { get; set; } = -0.3f; // looking slightly down
|
||||
public float FovY { get; set; } = MathF.PI / 3f;
|
||||
public float Aspect { get; set; } = 16f / 9f;
|
||||
|
||||
public float MoveSpeed { get; set; } = 100f; // world units per second
|
||||
public float MouseSensitivity { get; set; } = 0.003f;
|
||||
|
||||
private const float PitchLimit = 1.5533f; // ~89 degrees
|
||||
|
||||
public Matrix4x4 View
|
||||
{
|
||||
get
|
||||
{
|
||||
var forward = Forward();
|
||||
return Matrix4x4.CreateLookAt(Position, Position + forward, Vector3.UnitZ);
|
||||
}
|
||||
}
|
||||
|
||||
public Matrix4x4 Projection
|
||||
=> Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
|
||||
|
||||
/// <summary>
|
||||
/// Integrate position for one frame based on WASD + vertical keys.
|
||||
/// W/S move forward/back in the horizontal plane (ignoring pitch).
|
||||
/// A/D strafe left/right. Up/down translate along world Z.
|
||||
/// </summary>
|
||||
public void Update(double dt, bool w, bool a, bool s, bool d, bool up, bool down)
|
||||
{
|
||||
float step = (float)(MoveSpeed * dt);
|
||||
|
||||
// Forward in the horizontal plane (ignore pitch so W doesn't dive into ground).
|
||||
var flatForward = new Vector3(MathF.Cos(Yaw), MathF.Sin(Yaw), 0f);
|
||||
var right = new Vector3(MathF.Sin(Yaw), -MathF.Cos(Yaw), 0f);
|
||||
|
||||
if (w) Position += flatForward * step;
|
||||
if (s) Position -= flatForward * step;
|
||||
if (a) Position -= right * step;
|
||||
if (d) Position += right * step;
|
||||
if (up) Position += Vector3.UnitZ * step;
|
||||
if (down) Position -= Vector3.UnitZ * step;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply accumulated mouse deltas (pixels since last frame). Positive deltaX
|
||||
/// rotates the view to the right (decreases yaw), positive deltaY rotates
|
||||
/// down (decreases pitch).
|
||||
/// </summary>
|
||||
public void Look(float deltaX, float deltaY)
|
||||
{
|
||||
Yaw -= deltaX * MouseSensitivity;
|
||||
Pitch = Math.Clamp(Pitch - deltaY * MouseSensitivity, -PitchLimit, PitchLimit);
|
||||
}
|
||||
|
||||
private Vector3 Forward()
|
||||
{
|
||||
float cp = MathF.Cos(Pitch);
|
||||
return new Vector3(
|
||||
cp * MathF.Cos(Yaw),
|
||||
cp * MathF.Sin(Yaw),
|
||||
MathF.Sin(Pitch));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.Plugins;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
using Silk.NET.Input;
|
||||
using Silk.NET.Maths;
|
||||
|
|
@ -12,12 +11,15 @@ namespace AcDream.App.Rendering;
|
|||
public sealed class GameWindow : IDisposable
|
||||
{
|
||||
private readonly string _datDir;
|
||||
private readonly WorldGameState _worldGameState;
|
||||
private readonly WorldEvents _worldEvents;
|
||||
private IWindow? _window;
|
||||
private GL? _gl;
|
||||
private IInputContext? _input;
|
||||
private TerrainRenderer? _terrain;
|
||||
private Shader? _shader;
|
||||
private OrbitCamera? _camera;
|
||||
private CameraController? _cameraController;
|
||||
private IMouse? _capturedMouse;
|
||||
private DatCollection? _dats;
|
||||
private float _lastMouseX;
|
||||
private float _lastMouseY;
|
||||
|
|
@ -26,7 +28,12 @@ public sealed class GameWindow : IDisposable
|
|||
private TextureCache? _textureCache;
|
||||
private IReadOnlyList<AcDream.Core.World.WorldEntity> _entities = Array.Empty<AcDream.Core.World.WorldEntity>();
|
||||
|
||||
public GameWindow(string datDir) => _datDir = datDir;
|
||||
public GameWindow(string datDir, WorldGameState worldGameState, WorldEvents worldEvents)
|
||||
{
|
||||
_datDir = datDir;
|
||||
_worldGameState = worldGameState;
|
||||
_worldEvents = worldEvents;
|
||||
}
|
||||
|
||||
public void Run()
|
||||
{
|
||||
|
|
@ -44,6 +51,7 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
_window = Window.Create(options);
|
||||
_window.Load += OnLoad;
|
||||
_window.Update += OnUpdate;
|
||||
_window.Render += OnRender;
|
||||
_window.Closing += OnClosing;
|
||||
|
||||
|
|
@ -57,26 +65,49 @@ public sealed class GameWindow : IDisposable
|
|||
foreach (var kb in _input.Keyboards)
|
||||
kb.KeyDown += (_, key, _) =>
|
||||
{
|
||||
if (key == Key.Escape)
|
||||
_window!.Close();
|
||||
if (key == Key.F)
|
||||
_cameraController?.ToggleFly();
|
||||
else if (key == Key.Escape)
|
||||
{
|
||||
if (_cameraController?.IsFlyMode == true)
|
||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
||||
else
|
||||
_window!.Close();
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var mouse in _input.Mice)
|
||||
{
|
||||
mouse.MouseMove += (m, pos) =>
|
||||
{
|
||||
if (m.IsButtonPressed(MouseButton.Left))
|
||||
if (_cameraController is null) return;
|
||||
|
||||
if (_cameraController.IsFlyMode)
|
||||
{
|
||||
_camera!.Yaw -= (pos.X - _lastMouseX) * 0.005f;
|
||||
_camera!.Pitch = Math.Clamp(
|
||||
_camera.Pitch + (pos.Y - _lastMouseY) * 0.005f,
|
||||
0.1f, 1.5f);
|
||||
// Raw cursor mode: Silk.NET gives deltas via position. Compute delta from last.
|
||||
float dx = pos.X - _lastMouseX;
|
||||
float dy = pos.Y - _lastMouseY;
|
||||
_cameraController.Fly.Look(dx, dy);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (m.IsButtonPressed(MouseButton.Left))
|
||||
{
|
||||
_cameraController.Orbit.Yaw -= (pos.X - _lastMouseX) * 0.005f;
|
||||
_cameraController.Orbit.Pitch = Math.Clamp(
|
||||
_cameraController.Orbit.Pitch + (pos.Y - _lastMouseY) * 0.005f,
|
||||
0.1f, 1.5f);
|
||||
}
|
||||
}
|
||||
_lastMouseX = pos.X;
|
||||
_lastMouseY = pos.Y;
|
||||
};
|
||||
mouse.Scroll += (_, scroll) =>
|
||||
_camera!.Distance = Math.Clamp(_camera.Distance - scroll.Y * 20f, 50f, 2000f);
|
||||
{
|
||||
if (_cameraController is null || _cameraController.IsFlyMode) return;
|
||||
_cameraController.Orbit.Distance = Math.Clamp(
|
||||
_cameraController.Orbit.Distance - scroll.Y * 20f, 50f, 2000f);
|
||||
};
|
||||
}
|
||||
|
||||
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
|
||||
|
|
@ -91,82 +122,74 @@ public sealed class GameWindow : IDisposable
|
|||
Path.Combine(shadersDir, "mesh.vert"),
|
||||
Path.Combine(shadersDir, "mesh.frag"));
|
||||
|
||||
_camera = new OrbitCamera
|
||||
{
|
||||
Aspect = _window!.Size.X / (float)_window.Size.Y,
|
||||
};
|
||||
var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y };
|
||||
var fly = new FlyCamera { Aspect = _window.Size.X / (float)_window.Size.Y };
|
||||
_cameraController = new CameraController(orbit, fly);
|
||||
_cameraController.ModeChanged += OnCameraModeChanged;
|
||||
|
||||
_dats = new DatCollection(_datDir, DatAccessType.Read);
|
||||
|
||||
// Find ANY landblock ending in 0xFFFF. Holtburg 0xA9B4FFFF is a
|
||||
// good default; fall back to the first one we find. Using Get<T>
|
||||
// (returns null on miss) rather than TryGet to sidestep
|
||||
// [MaybeNullWhen(false)] nullable-generic analysis under
|
||||
// TreatWarningsAsErrors.
|
||||
uint landblockId = 0xA9B4FFFFu;
|
||||
var block = _dats.Get<LandBlock>(landblockId);
|
||||
if (block is null)
|
||||
{
|
||||
foreach (var file in _dats.Cell.Tree)
|
||||
{
|
||||
if ((file.Id & 0xFFFFu) == 0xFFFFu)
|
||||
{
|
||||
landblockId = file.Id;
|
||||
block = _dats.Get<LandBlock>(landblockId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
uint centerLandblockId = 0xA9B4FFFFu;
|
||||
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
|
||||
|
||||
if (block is null)
|
||||
throw new InvalidOperationException("no landblock found in cell dat");
|
||||
|
||||
Console.WriteLine($"loaded landblock 0x{landblockId:X8}");
|
||||
|
||||
// Load the non-linear LandHeightTable from the Region dat. AC encodes
|
||||
// per-vertex heights as byte indices into this 256-entry float table,
|
||||
// not as a simple * 2.0 ramp — building placements depend on the real
|
||||
// table, so terrain rendered with the simplified scale would leave
|
||||
// buildings floating or buried.
|
||||
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
|
||||
var heightTable = region?.LandDefs.LandHeightTable;
|
||||
if (heightTable is null || heightTable.Length < 256)
|
||||
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
|
||||
|
||||
var meshData = LandblockMesh.Build(block, heightTable);
|
||||
_terrain = new TerrainRenderer(_gl, meshData, _shader);
|
||||
// Build the terrain atlas once from the Region dat.
|
||||
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats);
|
||||
|
||||
_terrain = new TerrainRenderer(_gl, _shader, terrainAtlas);
|
||||
|
||||
// Load the 3x3 neighbor grid.
|
||||
var worldView = AcDream.Core.World.WorldView.Load(_dats, centerLandblockId);
|
||||
Console.WriteLine($"loaded {worldView.Landblocks.Count} landblocks in 3x3 grid");
|
||||
|
||||
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
|
||||
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
|
||||
|
||||
foreach (var lb in worldView.Landblocks)
|
||||
{
|
||||
var meshData = AcDream.Core.Terrain.LandblockMesh.Build(
|
||||
lb.Heightmap, heightTable, terrainAtlas.TerrainTypeToLayer);
|
||||
|
||||
// Compute world origin for this landblock relative to the center.
|
||||
int lbX = (int)((lb.LandblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((lb.LandblockId >> 16) & 0xFFu);
|
||||
var origin = new System.Numerics.Vector3(
|
||||
(lbX - centerX) * 192f,
|
||||
(lbY - centerY) * 192f,
|
||||
0f);
|
||||
|
||||
_terrain.AddLandblock(meshData, origin);
|
||||
}
|
||||
|
||||
_textureCache = new TextureCache(_gl, _dats);
|
||||
_staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache);
|
||||
|
||||
// Load LandBlockInfo for Holtburg, hydrate entities.
|
||||
var info = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
var entities = info is not null
|
||||
? AcDream.Core.World.LandblockLoader.BuildEntitiesFromInfo(info)
|
||||
: Array.Empty<AcDream.Core.World.WorldEntity>();
|
||||
// Hydrate entities from ALL loaded landblocks, not just the center.
|
||||
var allEntities = worldView.AllEntities.ToList();
|
||||
Console.WriteLine($"hydrating {allEntities.Count} entities across {worldView.Landblocks.Count} landblocks");
|
||||
|
||||
// Populate MeshRefs for each entity by resolving its source id to GfxObj or Setup
|
||||
// and extracting sub-meshes. Store back onto the entity. Since WorldEntity is
|
||||
// `required init`, we rebuild the entity here.
|
||||
var hydratedEntities = new List<AcDream.Core.World.WorldEntity>(entities.Count);
|
||||
foreach (var e in entities)
|
||||
var hydratedEntities = new List<AcDream.Core.World.WorldEntity>(allEntities.Count);
|
||||
foreach (var e in allEntities)
|
||||
{
|
||||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||
|
||||
if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
|
||||
{
|
||||
// GfxObj: one mesh ref with identity transform.
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
|
||||
if (gfx is not null)
|
||||
{
|
||||
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx);
|
||||
_staticMesh.EnsureUploaded(e.SourceGfxObjOrSetupId, subMeshes);
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(
|
||||
e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
|
||||
}
|
||||
}
|
||||
else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
// Setup: flatten into parts, upload each part's GfxObj.
|
||||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||||
if (setup is not null)
|
||||
{
|
||||
|
|
@ -184,26 +207,74 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
if (meshRefs.Count > 0)
|
||||
{
|
||||
hydratedEntities.Add(new AcDream.Core.World.WorldEntity
|
||||
// Add the landblock origin to the entity's position so the static
|
||||
// mesh renderer draws it at the correct world location.
|
||||
var sourceLandblock = worldView.Landblocks.First(lb => lb.Entities.Contains(e));
|
||||
int lbX = (int)((sourceLandblock.LandblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((sourceLandblock.LandblockId >> 16) & 0xFFu);
|
||||
var worldOffset = new System.Numerics.Vector3(
|
||||
(lbX - centerX) * 192f,
|
||||
(lbY - centerY) * 192f,
|
||||
0f);
|
||||
|
||||
var hydrated = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = e.Id,
|
||||
SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
|
||||
Position = e.Position,
|
||||
Position = e.Position + worldOffset,
|
||||
Rotation = e.Rotation,
|
||||
MeshRefs = meshRefs,
|
||||
});
|
||||
};
|
||||
hydratedEntities.Add(hydrated);
|
||||
|
||||
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
||||
Id: hydrated.Id,
|
||||
SourceId: hydrated.SourceGfxObjOrSetupId,
|
||||
Position: hydrated.Position,
|
||||
Rotation: hydrated.Rotation);
|
||||
_worldGameState.Add(snapshot);
|
||||
_worldEvents.FireEntitySpawned(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
_entities = hydratedEntities;
|
||||
Console.WriteLine($"hydrated {_entities.Count} entities on landblock 0x{landblockId:X8}");
|
||||
Console.WriteLine($"hydrated {_entities.Count} entities");
|
||||
}
|
||||
|
||||
private void OnUpdate(double dt)
|
||||
{
|
||||
if (_cameraController is null || _input is null) return;
|
||||
if (!_cameraController.IsFlyMode) return;
|
||||
|
||||
var kb = _input.Keyboards[0];
|
||||
_cameraController.Fly.Update(
|
||||
dt,
|
||||
w: kb.IsKeyPressed(Key.W),
|
||||
a: kb.IsKeyPressed(Key.A),
|
||||
s: kb.IsKeyPressed(Key.S),
|
||||
d: kb.IsKeyPressed(Key.D),
|
||||
up: kb.IsKeyPressed(Key.Space),
|
||||
down: kb.IsKeyPressed(Key.ControlLeft));
|
||||
}
|
||||
|
||||
private void OnCameraModeChanged(bool isFlyMode)
|
||||
{
|
||||
if (_input is null) return;
|
||||
var mouse = _input.Mice.FirstOrDefault();
|
||||
if (mouse is null) return;
|
||||
|
||||
mouse.Cursor.CursorMode = isFlyMode ? CursorMode.Raw : CursorMode.Normal;
|
||||
_capturedMouse = isFlyMode ? mouse : null;
|
||||
}
|
||||
|
||||
private void OnRender(double deltaSeconds)
|
||||
{
|
||||
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||
_terrain?.Draw(_camera!);
|
||||
_staticMesh?.Draw(_camera!, _entities);
|
||||
if (_cameraController is not null)
|
||||
{
|
||||
_terrain?.Draw(_cameraController.Active);
|
||||
_staticMesh?.Draw(_cameraController.Active, _entities);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnClosing()
|
||||
|
|
|
|||
10
src/AcDream.App/Rendering/ICamera.cs
Normal file
10
src/AcDream.App/Rendering/ICamera.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
public interface ICamera
|
||||
{
|
||||
Matrix4x4 View { get; }
|
||||
Matrix4x4 Projection { get; }
|
||||
float Aspect { get; set; }
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ using System.Numerics;
|
|||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
public sealed class OrbitCamera
|
||||
public sealed class OrbitCamera : ICamera
|
||||
{
|
||||
public Vector3 Target { get; set; } = new(96, 96, 0); // center of a 192x192 landblock
|
||||
public float Distance { get; set; } = 300f;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
#version 430 core
|
||||
in float vHeight;
|
||||
in vec2 vTex;
|
||||
in flat uint vLayer;
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform sampler2DArray uAtlas;
|
||||
|
||||
void main() {
|
||||
float t = clamp(vHeight / 200.0, 0.0, 1.0);
|
||||
vec3 low = vec3(0.10, 0.35, 0.15); // green lowland
|
||||
vec3 mid = vec3(0.55, 0.45, 0.25); // brown mid
|
||||
vec3 high = vec3(0.90, 0.90, 0.95); // snowy peak
|
||||
vec3 color = t < 0.5
|
||||
? mix(low, mid, t * 2.0)
|
||||
: mix(mid, high, (t - 0.5) * 2.0);
|
||||
fragColor = vec4(color, 1.0);
|
||||
fragColor = texture(uAtlas, vec3(vTex, float(vLayer)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,17 @@
|
|||
layout(location = 0) in vec3 aPos;
|
||||
layout(location = 1) in vec3 aNormal;
|
||||
layout(location = 2) in vec2 aTex;
|
||||
layout(location = 3) in uint aTerrainLayer;
|
||||
|
||||
uniform mat4 uModel;
|
||||
uniform mat4 uView;
|
||||
uniform mat4 uProjection;
|
||||
|
||||
out float vHeight;
|
||||
out vec2 vTex;
|
||||
out flat uint vLayer;
|
||||
|
||||
void main() {
|
||||
vHeight = aPos.z;
|
||||
gl_Position = uProjection * uView * vec4(aPos, 1.0);
|
||||
vTex = aTex;
|
||||
vLayer = aTerrainLayer;
|
||||
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ public sealed unsafe class StaticMeshRenderer : IDisposable
|
|||
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
|
||||
_gl.EnableVertexAttribArray(2);
|
||||
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
|
||||
_gl.EnableVertexAttribArray(3);
|
||||
_gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float)));
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
|
||||
|
|
@ -71,7 +73,7 @@ public sealed unsafe class StaticMeshRenderer : IDisposable
|
|||
};
|
||||
}
|
||||
|
||||
public void Draw(OrbitCamera camera, IEnumerable<WorldEntity> entities)
|
||||
public void Draw(ICamera camera, IEnumerable<WorldEntity> entities)
|
||||
{
|
||||
_shader.Use();
|
||||
_shader.SetMatrix4("uView", camera.View);
|
||||
|
|
|
|||
164
src/AcDream.App/Rendering/TerrainAtlas.cs
Normal file
164
src/AcDream.App/Rendering/TerrainAtlas.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
using AcDream.Core.Textures;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using Silk.NET.OpenGL;
|
||||
using DatPixelFormat = DatReaderWriter.Enums.PixelFormat;
|
||||
using GLPixelFormat = Silk.NET.OpenGL.PixelFormat;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a GL_TEXTURE_2D_ARRAY from the set of terrain types seen in the loaded
|
||||
/// landblocks, one layer per unique terrain type. LandblockMesh writes per-vertex
|
||||
/// layer indices into Vertex.TerrainLayer; the terrain fragment shader samples
|
||||
/// texture(uAtlas, vec3(uv, float(vLayer))).
|
||||
/// </summary>
|
||||
public sealed unsafe class TerrainAtlas : IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
public uint GlTexture { get; }
|
||||
public IReadOnlyDictionary<uint, uint> TerrainTypeToLayer { get; }
|
||||
public int LayerCount { get; }
|
||||
|
||||
private TerrainAtlas(GL gl, uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount)
|
||||
{
|
||||
_gl = gl;
|
||||
GlTexture = glTexture;
|
||||
TerrainTypeToLayer = map;
|
||||
LayerCount = layerCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the atlas by walking Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc
|
||||
/// for the mapping from TerrainTextureType to SurfaceTexture id, decoding each
|
||||
/// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY.
|
||||
/// </summary>
|
||||
public static TerrainAtlas Build(GL gl, DatCollection dats)
|
||||
{
|
||||
var region = dats.Get<Region>(0x13000000u)
|
||||
?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
|
||||
|
||||
var terrainDesc = region.TerrainInfo?.LandSurfaces?.TexMerge?.TerrainDesc;
|
||||
if (terrainDesc is null || terrainDesc.Count == 0)
|
||||
{
|
||||
// Fallback: upload a single 1x1 white layer as layer 0.
|
||||
Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
|
||||
return BuildFallback(gl);
|
||||
}
|
||||
|
||||
// Walk TerrainDesc. Each TMTerrainDesc has a TerrainType (enum cast to uint)
|
||||
// and a TerrainTex with a QualifiedDataId<SurfaceTexture> TextureId. Decode
|
||||
// each referenced SurfaceTexture → RenderSurface → RGBA8 via SurfaceDecoder.
|
||||
var decodedByType = new Dictionary<uint, DecodedTexture>();
|
||||
int maxW = 1, maxH = 1;
|
||||
foreach (var tmtd in terrainDesc)
|
||||
{
|
||||
uint typeKey = (uint)tmtd.TerrainType;
|
||||
if (decodedByType.ContainsKey(typeKey))
|
||||
continue;
|
||||
|
||||
var surfaceTextureId = (uint)tmtd.TerrainTex.TextureId;
|
||||
var st = dats.Get<SurfaceTexture>(surfaceTextureId);
|
||||
if (st is null || st.Textures.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"WARN: TerrainType {tmtd.TerrainType} SurfaceTexture 0x{surfaceTextureId:X8} missing");
|
||||
decodedByType[typeKey] = DecodedTexture.Magenta;
|
||||
continue;
|
||||
}
|
||||
|
||||
var rs = dats.Get<RenderSurface>((uint)st.Textures[0]);
|
||||
if (rs is null)
|
||||
{
|
||||
decodedByType[typeKey] = DecodedTexture.Magenta;
|
||||
continue;
|
||||
}
|
||||
|
||||
Palette? palette = rs.DefaultPaletteId != 0
|
||||
? dats.Get<Palette>(rs.DefaultPaletteId)
|
||||
: null;
|
||||
|
||||
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
|
||||
decodedByType[typeKey] = decoded;
|
||||
if (decoded.Width > maxW) maxW = decoded.Width;
|
||||
if (decoded.Height > maxH) maxH = decoded.Height;
|
||||
}
|
||||
|
||||
// Allocate the GL_TEXTURE_2D_ARRAY with the max dimensions seen. Textures
|
||||
// smaller than (maxW, maxH) are scaled up naively by nearest-neighbor
|
||||
// replication into a resized RGBA8 buffer. Phase 2b doesn't need mip chains.
|
||||
int layerCount = decodedByType.Count;
|
||||
uint tex = gl.GenTexture();
|
||||
gl.BindTexture(TextureTarget.Texture2DArray, tex);
|
||||
gl.TexImage3D(
|
||||
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
|
||||
(uint)maxW, (uint)maxH, (uint)layerCount,
|
||||
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
|
||||
|
||||
var map = new Dictionary<uint, uint>();
|
||||
int layerIdx = 0;
|
||||
foreach (var kvp in decodedByType)
|
||||
{
|
||||
byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH);
|
||||
fixed (byte* p = buffer)
|
||||
{
|
||||
gl.TexSubImage3D(
|
||||
TextureTarget.Texture2DArray, 0,
|
||||
0, 0, layerIdx,
|
||||
(uint)maxW, (uint)maxH, 1,
|
||||
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
|
||||
}
|
||||
map[kvp.Key] = (uint)layerIdx;
|
||||
layerIdx++;
|
||||
}
|
||||
|
||||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
||||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
|
||||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
|
||||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
|
||||
|
||||
gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
||||
|
||||
Console.WriteLine($"TerrainAtlas: {layerCount} layers at {maxW}x{maxH}");
|
||||
return new TerrainAtlas(gl, tex, map, layerCount);
|
||||
}
|
||||
|
||||
private static byte[] ResizeRgba8Nearest(DecodedTexture src, int dstW, int dstH)
|
||||
{
|
||||
if (src.Width == dstW && src.Height == dstH)
|
||||
return src.Rgba8;
|
||||
|
||||
var dst = new byte[dstW * dstH * 4];
|
||||
for (int y = 0; y < dstH; y++)
|
||||
{
|
||||
int srcY = y * src.Height / dstH;
|
||||
for (int x = 0; x < dstW; x++)
|
||||
{
|
||||
int srcX = x * src.Width / dstW;
|
||||
int si = (srcY * src.Width + srcX) * 4;
|
||||
int di = (y * dstW + x) * 4;
|
||||
dst[di + 0] = src.Rgba8[si + 0];
|
||||
dst[di + 1] = src.Rgba8[si + 1];
|
||||
dst[di + 2] = src.Rgba8[si + 2];
|
||||
dst[di + 3] = src.Rgba8[si + 3];
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
private static TerrainAtlas BuildFallback(GL gl)
|
||||
{
|
||||
uint tex = gl.GenTexture();
|
||||
gl.BindTexture(TextureTarget.Texture2DArray, tex);
|
||||
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
|
||||
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
|
||||
fixed (byte* p = white)
|
||||
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
|
||||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
||||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
|
||||
gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
||||
return new TerrainAtlas(gl, tex, new Dictionary<uint, uint> { [0] = 0u }, 1);
|
||||
}
|
||||
|
||||
public void Dispose() => _gl.DeleteTexture(GlTexture);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Terrain;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
|
|
@ -7,33 +8,39 @@ public sealed unsafe class TerrainRenderer : IDisposable
|
|||
{
|
||||
private readonly GL _gl;
|
||||
private readonly Shader _shader;
|
||||
private readonly uint _vao;
|
||||
private readonly uint _vbo;
|
||||
private readonly uint _ebo;
|
||||
private readonly int _indexCount;
|
||||
private readonly TerrainAtlas _atlas;
|
||||
private readonly List<LandblockGpu> _landblocks = new();
|
||||
|
||||
public TerrainRenderer(GL gl, LandblockMeshData meshData, Shader shader)
|
||||
public TerrainRenderer(GL gl, Shader shader, TerrainAtlas atlas)
|
||||
{
|
||||
_gl = gl;
|
||||
_shader = shader;
|
||||
_indexCount = meshData.Indices.Length;
|
||||
_atlas = atlas;
|
||||
}
|
||||
|
||||
_vao = _gl.GenVertexArray();
|
||||
_gl.BindVertexArray(_vao);
|
||||
public void AddLandblock(LandblockMeshData meshData, Vector3 worldOrigin)
|
||||
{
|
||||
var gpu = new LandblockGpu
|
||||
{
|
||||
Vao = _gl.GenVertexArray(),
|
||||
WorldOrigin = worldOrigin,
|
||||
IndexCount = meshData.Indices.Length,
|
||||
};
|
||||
|
||||
_vbo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
|
||||
_gl.BindVertexArray(gpu.Vao);
|
||||
|
||||
gpu.Vbo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, gpu.Vbo);
|
||||
fixed (void* p = meshData.Vertices)
|
||||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
||||
(nuint)(meshData.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
|
||||
|
||||
_ebo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ebo);
|
||||
gpu.Ebo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, gpu.Ebo);
|
||||
fixed (void* p = meshData.Indices)
|
||||
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
|
||||
(nuint)(meshData.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
|
||||
|
||||
// vertex layout: position(3f), normal(3f), texcoord(2f) = 8 floats stride
|
||||
uint stride = (uint)sizeof(Vertex);
|
||||
_gl.EnableVertexAttribArray(0);
|
||||
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
|
||||
|
|
@ -41,24 +48,49 @@ public sealed unsafe class TerrainRenderer : IDisposable
|
|||
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
|
||||
_gl.EnableVertexAttribArray(2);
|
||||
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
|
||||
_gl.EnableVertexAttribArray(3);
|
||||
_gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float)));
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
_landblocks.Add(gpu);
|
||||
}
|
||||
|
||||
public void Draw(OrbitCamera camera)
|
||||
public void Draw(ICamera camera)
|
||||
{
|
||||
_shader.Use();
|
||||
_shader.SetMatrix4("uView", camera.View);
|
||||
_shader.SetMatrix4("uProjection", camera.Projection);
|
||||
_gl.BindVertexArray(_vao);
|
||||
_gl.DrawElements(PrimitiveType.Triangles, (uint)_indexCount, DrawElementsType.UnsignedInt, (void*)0);
|
||||
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlTexture);
|
||||
|
||||
foreach (var lb in _landblocks)
|
||||
{
|
||||
var model = Matrix4x4.CreateTranslation(lb.WorldOrigin);
|
||||
_shader.SetMatrix4("uModel", model);
|
||||
_gl.BindVertexArray(lb.Vao);
|
||||
_gl.DrawElements(PrimitiveType.Triangles, (uint)lb.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
|
||||
}
|
||||
_gl.BindVertexArray(0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_gl.DeleteBuffer(_vbo);
|
||||
_gl.DeleteBuffer(_ebo);
|
||||
_gl.DeleteVertexArray(_vao);
|
||||
foreach (var lb in _landblocks)
|
||||
{
|
||||
_gl.DeleteBuffer(lb.Vbo);
|
||||
_gl.DeleteBuffer(lb.Ebo);
|
||||
_gl.DeleteVertexArray(lb.Vao);
|
||||
}
|
||||
_landblocks.Clear();
|
||||
}
|
||||
|
||||
private sealed class LandblockGpu
|
||||
{
|
||||
public uint Vao;
|
||||
public uint Vbo;
|
||||
public uint Ebo;
|
||||
public int IndexCount;
|
||||
public Vector3 WorldOrigin;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ public static class GfxObjMesh
|
|||
if (!bucket.Dedupe.TryGetValue(key, out var outIdx))
|
||||
{
|
||||
outIdx = (uint)bucket.Vertices.Count;
|
||||
bucket.Vertices.Add(new Vertex(sw.Origin, sw.Normal, texcoord));
|
||||
bucket.Vertices.Add(new Vertex(sw.Origin, sw.Normal, texcoord, TerrainLayer: 0));
|
||||
bucket.Dedupe[key] = outIdx;
|
||||
}
|
||||
polyOut.Add(outIdx);
|
||||
|
|
|
|||
56
src/AcDream.Core/Plugins/WorldEvents.cs
Normal file
56
src/AcDream.Core/Plugins/WorldEvents.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// src/AcDream.Core/Plugins/WorldEvents.cs
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Plugins;
|
||||
|
||||
public sealed class WorldEvents : IEvents
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly List<WorldEntitySnapshot> _alreadySpawned = new();
|
||||
private Action<WorldEntitySnapshot>? _subscribers;
|
||||
|
||||
/// <summary>
|
||||
/// Called by the host as each entity is hydrated into the world. Records the
|
||||
/// snapshot for later replay and dispatches to current subscribers.
|
||||
/// </summary>
|
||||
public void FireEntitySpawned(WorldEntitySnapshot snapshot)
|
||||
{
|
||||
Action<WorldEntitySnapshot>? toNotify;
|
||||
lock (_lock)
|
||||
{
|
||||
_alreadySpawned.Add(snapshot);
|
||||
toNotify = _subscribers;
|
||||
}
|
||||
|
||||
if (toNotify is null) return;
|
||||
foreach (Action<WorldEntitySnapshot> handler in toNotify.GetInvocationList())
|
||||
{
|
||||
try { handler(snapshot); }
|
||||
catch { /* plugin errors don't propagate out of event dispatch */ }
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||
{
|
||||
add
|
||||
{
|
||||
WorldEntitySnapshot[] replay;
|
||||
lock (_lock)
|
||||
{
|
||||
_subscribers += value;
|
||||
replay = _alreadySpawned.ToArray();
|
||||
}
|
||||
// Replay outside the lock to avoid deadlock if a handler re-enters.
|
||||
foreach (var s in replay)
|
||||
{
|
||||
try { value(s); }
|
||||
catch { /* plugin errors don't propagate out of += */ }
|
||||
}
|
||||
}
|
||||
remove
|
||||
{
|
||||
lock (_lock)
|
||||
_subscribers -= value;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/AcDream.Core/Plugins/WorldGameState.cs
Normal file
14
src/AcDream.Core/Plugins/WorldGameState.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// src/AcDream.Core/Plugins/WorldGameState.cs
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Plugins;
|
||||
|
||||
public sealed class WorldGameState : IGameState
|
||||
{
|
||||
private readonly List<WorldEntitySnapshot> _entities = new();
|
||||
|
||||
public IReadOnlyList<WorldEntitySnapshot> Entities => _entities;
|
||||
|
||||
/// <summary>Called by the host as each entity is hydrated.</summary>
|
||||
public void Add(WorldEntitySnapshot snapshot) => _entities.Add(snapshot);
|
||||
}
|
||||
|
|
@ -7,19 +7,20 @@ public sealed record LandblockMeshData(Vertex[] Vertices, uint[] Indices);
|
|||
|
||||
public static class LandblockMesh
|
||||
{
|
||||
// AC landblock geometry constants
|
||||
private const int VerticesPerSide = 9; // 9x9 heightmap grid
|
||||
private const int CellsPerSide = VerticesPerSide - 1; // 8x8 cells
|
||||
private const float CellSize = 24.0f; // world units per cell edge
|
||||
private const int VerticesPerSide = 9;
|
||||
private const int CellsPerSide = VerticesPerSide - 1;
|
||||
private const float CellSize = 24.0f;
|
||||
// Phase 2b: tile terrain textures ~4x per landblock instead of stretching
|
||||
// a single texture across the whole 192-unit patch.
|
||||
private const float TexCoordDivisor = CellsPerSide / 4.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Build the CPU mesh for one landblock's heightmap. <paramref name="heightTable"/>
|
||||
/// is the 256-entry non-linear height lookup from <c>Region.LandDefs.LandHeightTable</c> —
|
||||
/// AC encodes per-vertex heights as indices into this table, not raw world-Z.
|
||||
/// </summary>
|
||||
public static LandblockMeshData Build(LandBlock block, float[] heightTable)
|
||||
public static LandblockMeshData Build(
|
||||
LandBlock block,
|
||||
float[] heightTable,
|
||||
IReadOnlyDictionary<uint, uint> terrainTypeToLayer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(heightTable);
|
||||
ArgumentNullException.ThrowIfNull(terrainTypeToLayer);
|
||||
if (heightTable.Length < 256)
|
||||
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
|
||||
|
||||
|
|
@ -28,23 +29,24 @@ public static class LandblockMesh
|
|||
{
|
||||
for (int x = 0; x < VerticesPerSide; x++)
|
||||
{
|
||||
// Vertex buffer index (row-major, y*9+x) is internal to this mesh
|
||||
// and what the index buffer below references.
|
||||
int vi = y * VerticesPerSide + x;
|
||||
|
||||
// Height dat index is PACKED AS x*9+y — AC stores per-vertex
|
||||
// heights in x-major order (see ACViewer's
|
||||
// LandblockStruct: Height[x * VertexDim + y]). Using y*9+x here
|
||||
// (as Phase 1 did) transposes the terrain along its diagonal,
|
||||
// which is invisible for flat landblocks but leaves buildings
|
||||
// buried by ~10+ units on real terrain like Holtburg.
|
||||
int hi = x * VerticesPerSide + y;
|
||||
|
||||
float height = heightTable[block.Height[hi]];
|
||||
|
||||
// TerrainInfo is bit-packed: bits 0-1 Road, bits 2-6 Type (5-bit
|
||||
// TerrainTextureType enum), bits 11-15 Scenery. The atlas keys on
|
||||
// Type only, matching Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc
|
||||
// which lists SurfaceTexture ids per TerrainTextureType.
|
||||
uint terrainType = (uint)block.Terrain[hi].Type;
|
||||
if (!terrainTypeToLayer.TryGetValue(terrainType, out var layer))
|
||||
layer = 0;
|
||||
|
||||
vertices[vi] = new Vertex(
|
||||
Position: new Vector3(x * CellSize, y * CellSize, height),
|
||||
Normal: Vector3.UnitZ,
|
||||
TexCoord: new Vector2(x / (float)CellsPerSide, y / (float)CellsPerSide));
|
||||
TexCoord: new Vector2(x / TexCoordDivisor, y / TexCoordDivisor),
|
||||
TerrainLayer: layer);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +60,6 @@ public static class LandblockMesh
|
|||
uint b = (uint)(y * VerticesPerSide + x + 1);
|
||||
uint c = (uint)((y + 1) * VerticesPerSide + x);
|
||||
uint d = (uint)((y + 1) * VerticesPerSide + x + 1);
|
||||
// two triangles per cell, CCW
|
||||
indices[idx++] = a; indices[idx++] = b; indices[idx++] = d;
|
||||
indices[idx++] = a; indices[idx++] = d; indices[idx++] = c;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,8 @@ using System.Numerics;
|
|||
|
||||
namespace AcDream.Core.Terrain;
|
||||
|
||||
public readonly record struct Vertex(Vector3 Position, Vector3 Normal, Vector2 TexCoord);
|
||||
public readonly record struct Vertex(
|
||||
Vector3 Position,
|
||||
Vector3 Normal,
|
||||
Vector2 TexCoord,
|
||||
uint TerrainLayer);
|
||||
|
|
|
|||
7
src/AcDream.Plugin.Abstractions/IEvents.cs
Normal file
7
src/AcDream.Plugin.Abstractions/IEvents.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// src/AcDream.Plugin.Abstractions/IEvents.cs
|
||||
namespace AcDream.Plugin.Abstractions;
|
||||
|
||||
public interface IEvents
|
||||
{
|
||||
event Action<WorldEntitySnapshot> EntitySpawned;
|
||||
}
|
||||
7
src/AcDream.Plugin.Abstractions/IGameState.cs
Normal file
7
src/AcDream.Plugin.Abstractions/IGameState.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// src/AcDream.Plugin.Abstractions/IGameState.cs
|
||||
namespace AcDream.Plugin.Abstractions;
|
||||
|
||||
public interface IGameState
|
||||
{
|
||||
IReadOnlyList<WorldEntitySnapshot> Entities { get; }
|
||||
}
|
||||
|
|
@ -3,9 +3,11 @@ namespace AcDream.Plugin.Abstractions;
|
|||
|
||||
/// <summary>
|
||||
/// Entry point for a plugin into the acdream runtime. The surface will grow
|
||||
/// across phases as more systems come online. For Phase 1 only IPluginLogger is real.
|
||||
/// across phases as more systems come online.
|
||||
/// </summary>
|
||||
public interface IPluginHost
|
||||
{
|
||||
IPluginLogger Log { get; }
|
||||
IGameState State { get; }
|
||||
IEvents Events { get; }
|
||||
}
|
||||
|
|
|
|||
10
src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs
Normal file
10
src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Plugin.Abstractions;
|
||||
|
||||
public readonly record struct WorldEntitySnapshot(
|
||||
uint Id,
|
||||
uint SourceId,
|
||||
Vector3 Position,
|
||||
Quaternion Rotation);
|
||||
|
|
@ -5,6 +5,7 @@ namespace AcDream.Plugins.Smoke;
|
|||
public sealed class SmokePlugin : IAcDreamPlugin
|
||||
{
|
||||
private IPluginHost? _host;
|
||||
private int _entitiesSeen;
|
||||
|
||||
public void Initialize(IPluginHost host)
|
||||
{
|
||||
|
|
@ -12,6 +13,22 @@ public sealed class SmokePlugin : IAcDreamPlugin
|
|||
_host.Log.Info("smoke plugin initialized");
|
||||
}
|
||||
|
||||
public void Enable() => _host?.Log.Info("smoke plugin enabled");
|
||||
public void Disable() => _host?.Log.Info("smoke plugin disabled");
|
||||
public void Enable()
|
||||
{
|
||||
_host?.Log.Info("smoke plugin enabled");
|
||||
if (_host is not null)
|
||||
{
|
||||
_host.Events.EntitySpawned += OnEntitySpawned;
|
||||
_host.Log.Info($"smoke plugin sees {_entitiesSeen} entities (replay count at subscribe)");
|
||||
}
|
||||
}
|
||||
|
||||
public void Disable()
|
||||
{
|
||||
if (_host is not null)
|
||||
_host.Events.EntitySpawned -= OnEntitySpawned;
|
||||
_host?.Log.Info($"smoke plugin disabled (saw {_entitiesSeen} entities total)");
|
||||
}
|
||||
|
||||
private void OnEntitySpawned(WorldEntitySnapshot snapshot) => _entitiesSeen++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ public class PluginLoaderTests
|
|||
private sealed class StubHost : IPluginHost
|
||||
{
|
||||
public IPluginLogger Log { get; } = new StubLogger();
|
||||
public IGameState State { get; } = new StubState();
|
||||
public IEvents Events { get; } = new StubEvents();
|
||||
}
|
||||
|
||||
private sealed class StubLogger : IPluginLogger
|
||||
|
|
@ -37,6 +39,20 @@ public class PluginLoaderTests
|
|||
public void Error(string message, Exception? exception = null) { }
|
||||
}
|
||||
|
||||
private sealed class StubState : IGameState
|
||||
{
|
||||
public IReadOnlyList<WorldEntitySnapshot> Entities { get; } = Array.Empty<WorldEntitySnapshot>();
|
||||
}
|
||||
|
||||
private sealed class StubEvents : IEvents
|
||||
{
|
||||
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize()
|
||||
{
|
||||
|
|
|
|||
87
tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs
Normal file
87
tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Plugins;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Tests.Plugins;
|
||||
|
||||
public class WorldEventsTests
|
||||
{
|
||||
private static WorldEntitySnapshot S(uint id) => new(id, SourceId: 0x01000000u, Position: Vector3.Zero, Rotation: Quaternion.Identity);
|
||||
|
||||
[Fact]
|
||||
public void FireBeforeAnySubscriber_LateSubscribeReceivesReplay()
|
||||
{
|
||||
var events = new WorldEvents();
|
||||
events.FireEntitySpawned(S(1));
|
||||
events.FireEntitySpawned(S(2));
|
||||
events.FireEntitySpawned(S(3));
|
||||
|
||||
var seen = new List<uint>();
|
||||
events.EntitySpawned += e => seen.Add(e.Id);
|
||||
|
||||
Assert.Equal(new uint[] { 1, 2, 3 }, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FireAfterSubscribe_ReachesSubscriber()
|
||||
{
|
||||
var events = new WorldEvents();
|
||||
var seen = new List<uint>();
|
||||
events.EntitySpawned += e => seen.Add(e.Id);
|
||||
|
||||
events.FireEntitySpawned(S(10));
|
||||
events.FireEntitySpawned(S(20));
|
||||
|
||||
Assert.Equal(new uint[] { 10, 20 }, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayPlusLive_DeliversExactlyOnceEach()
|
||||
{
|
||||
var events = new WorldEvents();
|
||||
events.FireEntitySpawned(S(1)); // pre-subscribe
|
||||
|
||||
var seen = new List<uint>();
|
||||
events.EntitySpawned += e => seen.Add(e.Id); // replay fires 1
|
||||
|
||||
events.FireEntitySpawned(S(2)); // live fires 2
|
||||
|
||||
Assert.Equal(new uint[] { 1, 2 }, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unsubscribe_StopsLiveDelivery()
|
||||
{
|
||||
var events = new WorldEvents();
|
||||
var seen = new List<uint>();
|
||||
Action<WorldEntitySnapshot> handler = e => seen.Add(e.Id);
|
||||
|
||||
events.EntitySpawned += handler;
|
||||
events.FireEntitySpawned(S(1));
|
||||
events.EntitySpawned -= handler;
|
||||
events.FireEntitySpawned(S(2));
|
||||
|
||||
Assert.Equal(new uint[] { 1 }, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlerThrowsDuringReplay_OtherReplayEntriesStillDelivered()
|
||||
{
|
||||
var events = new WorldEvents();
|
||||
events.FireEntitySpawned(S(1));
|
||||
events.FireEntitySpawned(S(2));
|
||||
events.FireEntitySpawned(S(3));
|
||||
|
||||
var seen = new List<uint>();
|
||||
events.EntitySpawned += e =>
|
||||
{
|
||||
if (e.Id == 2) throw new InvalidOperationException("boom");
|
||||
seen.Add(e.Id);
|
||||
};
|
||||
|
||||
// No exception propagates out of the += add; 1 and 3 were still delivered.
|
||||
Assert.Contains(1u, seen);
|
||||
Assert.Contains(3u, seen);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,9 @@ public class LandblockMeshTests
|
|||
private static readonly float[] IdentityHeightTable =
|
||||
Enumerable.Range(0, 256).Select(i => i * 2f).ToArray();
|
||||
|
||||
private static readonly IReadOnlyDictionary<uint, uint> EmptyTerrainMap =
|
||||
new Dictionary<uint, uint>();
|
||||
|
||||
private static LandBlock BuildFlatLandBlock(byte heightIndex = 0)
|
||||
{
|
||||
var block = new LandBlock
|
||||
|
|
@ -36,7 +39,7 @@ public class LandblockMeshTests
|
|||
{
|
||||
var block = BuildFlatLandBlock();
|
||||
|
||||
var mesh = LandblockMesh.Build(block, IdentityHeightTable);
|
||||
var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap);
|
||||
|
||||
Assert.Equal(81, mesh.Vertices.Length);
|
||||
Assert.Equal(128 * 3, mesh.Indices.Length);
|
||||
|
|
@ -47,7 +50,7 @@ public class LandblockMeshTests
|
|||
{
|
||||
var block = BuildFlatLandBlock();
|
||||
|
||||
var mesh = LandblockMesh.Build(block, IdentityHeightTable);
|
||||
var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap);
|
||||
|
||||
var minX = mesh.Vertices.Min(v => v.Position.X);
|
||||
var maxX = mesh.Vertices.Max(v => v.Position.X);
|
||||
|
|
@ -65,7 +68,7 @@ public class LandblockMeshTests
|
|||
{
|
||||
var block = BuildFlatLandBlock(heightIndex: 10);
|
||||
|
||||
var mesh = LandblockMesh.Build(block, IdentityHeightTable);
|
||||
var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap);
|
||||
|
||||
var zs = mesh.Vertices.Select(v => v.Position.Z).Distinct().ToArray();
|
||||
Assert.Single(zs);
|
||||
|
|
@ -76,12 +79,38 @@ public class LandblockMeshTests
|
|||
{
|
||||
var block = BuildFlatLandBlock(heightIndex: 5);
|
||||
|
||||
var mesh = LandblockMesh.Build(block, IdentityHeightTable);
|
||||
var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap);
|
||||
|
||||
// AC's Land::LandHeightTable scales height byte index by 2.0f for the simple ramp case.
|
||||
Assert.Equal(10.0f, mesh.Vertices[0].Position.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_PerVertexTerrainLayer_UsesMappedLayerIndex()
|
||||
{
|
||||
var block = BuildFlatLandBlock();
|
||||
// TerrainInfo is bit-packed: bits 0-1 Road, bits 2-6 Type, bits 11-15 Scenery.
|
||||
// Raw ushort 0x001C = binary 0011100 → Type field = 7 (bits 2-6).
|
||||
// This is what a terrain sample with TerrainTextureType=7 looks like in the
|
||||
// underlying byte stream. LandblockMesh uses TerrainInfo.Type (not raw) as
|
||||
// the atlas lookup key.
|
||||
block.Terrain[2 * 9 + 3] = (ushort)(7 << 2); // Type=7, Road=0, Scenery=0
|
||||
|
||||
var map = new Dictionary<uint, uint>
|
||||
{
|
||||
[0] = 0u, // default type → atlas layer 0
|
||||
[7] = 4u, // TerrainTextureType 7 → atlas layer 4
|
||||
};
|
||||
|
||||
var mesh = LandblockMesh.Build(block, IdentityHeightTable, map);
|
||||
|
||||
// Vertex buffer internal order is y*9+x, so vertex at world (x=2, y=3) is at
|
||||
// index 3*9+2 = 29.
|
||||
Assert.Equal(4u, mesh.Vertices[3 * 9 + 2].TerrainLayer);
|
||||
// An untouched vertex still has Type 0, maps to layer 0.
|
||||
Assert.Equal(0u, mesh.Vertices[0].TerrainLayer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_HeightmapPackedAsXMajor_NotYMajor()
|
||||
{
|
||||
|
|
@ -98,7 +127,7 @@ public class LandblockMeshTests
|
|||
var block = BuildFlatLandBlock();
|
||||
block.Height[2 * 9 + 0] = 5; // x=2, y=0 in x-major packing
|
||||
|
||||
var mesh = LandblockMesh.Build(block, IdentityHeightTable);
|
||||
var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap);
|
||||
|
||||
// Find vertices by position. Vertex buffer uses y*9+x internally.
|
||||
var vAt_x2_y0 = mesh.Vertices[0 * 9 + 2]; // world (48, 0)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue