mirror of
https://github.com/Heretek-AI/game-engine.git
synced 2026-11-07 10:55:58 -10:00
* parity: 180 dropped-model upload rewrites mesh_id with registry id * parity: 180 guard upload null-call for Sonar S2259 (reliability gate) * parity: 180 use MappedFile for upload (MSVC C4996 fopen under /WX) --------- Co-authored-by: hermes-agent <hermes-agent@local>
This commit is contained in:
1 parent
d93c4d3dc2
commit
acc87d1f13
11 files changed
+589
-8
No files matched your search
@@ -0,0 +1,72 @@
|
||||
# P3-08-followup — #180: dropped-model mesh_id hash vs registry id
|
||||
|
||||
## 2026-11-04 — Spec read + branch + ABI sequencing
|
||||
- Read `docs/PARITY_IMPLEMENTATION_PROMPT.md` §§2, 7, 8 + §6 P3 line + §4.5
|
||||
before code. Issue #180: `EditorSession::drop_asset` writes
|
||||
MeshRenderer.mesh_id as an FNV-1a content hash, but MeshRegistry hands
|
||||
out sequential ids from renderer uploads — dropped models never
|
||||
resolve/draw (rare hash collision risk too). #180 is extra scope (NOT
|
||||
in the #56 done-when #65–#138); still worth fixing since P3-08
|
||||
acceptance ("visible in one frame") depends on it.
|
||||
- `git fetch`; branch `parity/p3-08-mesh-id-180` from `origin/main`
|
||||
(`2ac48ce`, P4-10 merge). P3-02 #88 is CLOSED (viewport path exists:
|
||||
`ViewportPresenter` + `Renderer.CreateOffscreen`/`MeshCreate` in-tree).
|
||||
- ABI sequencing (rule: append at END, minor +1, next unused): base minor
|
||||
= **25** (P3-05 `entity_remove` tail, table 1288B). This slice takes
|
||||
**4.26** with ONE entry: `renderer_upload_mesh(renderer, asset_path) ->
|
||||
mesh_id`. No other open branch in this worktree touches the ABI.
|
||||
|
||||
## 2026-11-04 — Design (why a renderer-side upload, not an assets change)
|
||||
- The MeshRegistry/RenderImpl/GpuMesh/cgltf-include all live on the
|
||||
renderer side, and the bridge already links assets+cgltf
|
||||
(`ENGINE_ASSETS_HAS_CGLTF` + include dir are PUBLIC usage from
|
||||
`engine_core_assets`; the bridge gets both for free). A new assets-side
|
||||
API would add a cross-layer surface for one viewport call — rejected.
|
||||
- `EditorSession::drop_asset` keeps writing the FNV-1a content hash as
|
||||
the spawn-time placeholder (deterministic, fail-closed codes
|
||||
unchanged). The viewport uploads via path vertices (flat shaded when
|
||||
NORMAL absent) and rewrites `MeshRenderer.mesh_id` through `Ecs.Set`.
|
||||
Fail-closed: no-GPU → content id stays, draw skips since
|
||||
`registry.find` fails (never a collision-driven wrong draw).
|
||||
- cgltf note: `asset_importer.cpp` is the single CGLTF_IMPLEMENTATION TU
|
||||
(dual definitions break the `engine_core` link — hit during build,
|
||||
fixed by header-only include here). Same POSITION/NORMAL/TEXCOORD_0
|
||||
decode rules as the M5-01 background lane; rejects > 4M verts /
|
||||
> 12M indices before allocating (corrupt count fields fail closed).
|
||||
- Managed: `Renderer.UploadMesh(path)` (throws on 0, caller picks
|
||||
skip-vs-fail) + `ProjectBrowser.DropModelUploaded(path, x, z, renderer)`
|
||||
(drop → upload → `Ecs.Set` rewrite, returns registered id). The
|
||||
existing `DropModel` path is untouched (content hash stays for no-GPU).
|
||||
|
||||
## 2026-11-04 — Acceptance commands + real output
|
||||
- `cmake --preset ci-linux` (FetchContent pinned to /tmp/bloom-v3 _deps)
|
||||
+ `cmake --build --preset ci-linux --target engine_project_drop
|
||||
engine_abi_layout`: clean, 0 warn/err.
|
||||
- `SDL_VIDEO_DRIVER=offscreen ENGINE_REQUIRE_GPU=1 ctest -R
|
||||
"abi_layout|project_drop"`: 2/2 pass. `engine_project_drop` prints 43
|
||||
`[PASS]` + `PROJECT_DROP_OK`: 30 pre-existing (one entity + valid
|
||||
mesh_id, determinism, .meta minted, 5-code failure matrix, undo) + 13
|
||||
new #180 (`renderer_upload_mesh` set, null args → 0, ABI world +
|
||||
offscreen renderer, upload → registry id **1**, second upload → **2**
|
||||
sequential, missing file → 0, MeshRenderer rewrite = registered id).
|
||||
- `dotnet build Engine.slnx`: 0 warn / 0 err.
|
||||
- `dotnet run --project managed/Engine.Tests`: 1190 `[PASS]`; new
|
||||
`live: upload returns registered mesh_id`, `live: spawn-time hash
|
||||
differs from registered id (#180)`, `live: rewritten mesh_id is the
|
||||
registered id`, `live: upload null path → 0`; `EditorApiTests: all
|
||||
checks passed`. Remaining `[FAIL]`s are pre-existing env issues
|
||||
(engine-cli `libhostpolicy.so` not found outside the staged
|
||||
compositor_live dir; `--live-native-probe` without
|
||||
`ENGINE_UI_FONT_DIR`/staged `engine_core` skips the same way on main).
|
||||
- `ENGINE_UI_FONT_DIR=$PWD/build/ci-linux/ui_fonts ... --live-native-probe`:
|
||||
`UiApiTests: all checks passed`, `EditorApiTests: all checks passed`
|
||||
(incl. the pick-gizmo offscreen renderer suite on the same world).
|
||||
|
||||
## NOT done (follow-ups, not this slice)
|
||||
- Avalonia `SceneViewportView` does not call `DropModelUploaded` yet
|
||||
(the view file exists but has no drop wiring on main) — the native +
|
||||
library halves this slice adds are the acceptance surface; the view
|
||||
binding lands with the P3-10/P3-12 panel work.
|
||||
- Redo of a drop re-drops at the same ground point (first Apply stays a
|
||||
no-op) — unchanged from P3-08; the redo path re-uploads on next
|
||||
`DropModelUploaded` call (registry ids are per-renderer, not stable).
|
||||
@@ -1,7 +1,7 @@
|
||||
// <auto-generated>
|
||||
// NativeApi.g.cs — GENERATED by tools/abi_codegen from
|
||||
// native/bridge/include/engine/bridge/engine_abi.h. DO NOT EDIT.
|
||||
// ABI 4.25.0, column stride 64.
|
||||
// ABI 4.26.0, column stride 64.
|
||||
// Regenerate: dotnet run --project tools/abi_codegen -- <repo-root>
|
||||
// </auto-generated>
|
||||
#nullable enable
|
||||
@@ -25,7 +25,7 @@ namespace Engine.Bindings;
|
||||
public static unsafe class NativeApi
|
||||
{
|
||||
public const int AbiVersionMajor = 4;
|
||||
public const int AbiVersionMinor = 25;
|
||||
public const int AbiVersionMinor = 26;
|
||||
public const int AbiVersionPatch = 0;
|
||||
public const int ColumnStride = 64;
|
||||
public const string NativeLibraryName = "engine_core";
|
||||
@@ -638,6 +638,7 @@ public static unsafe class NativeApi
|
||||
public delegate* unmanaged[Cdecl]<EngineWorld*, byte*, int> EngineComponentFieldCount;
|
||||
public delegate* unmanaged[Cdecl]<EngineWorld*, byte*, uint, FieldDesc*, int> EngineComponentFieldAt;
|
||||
public delegate* unmanaged[Cdecl]<EngineWorld*, ulong, ulong, int> EngineEntityRemove;
|
||||
public delegate* unmanaged[Cdecl]<EngineRenderer*, byte*, uint> EngineRendererUploadMesh;
|
||||
}
|
||||
|
||||
/// <summary>Resolves engine_get_api_table and validates the ABI.</summary>
|
||||
|
||||
@@ -103,6 +103,31 @@ public sealed unsafe class ProjectBrowser
|
||||
return entity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #180: DropModel + viewport upload in one call. Drops the model
|
||||
/// (content-hash mesh_id placeholder), uploads it into
|
||||
/// <paramref name="renderer"/>'s MeshRegistry, and rewrites the
|
||||
/// spawned MeshRenderer.MeshId with the registered id so the drop
|
||||
/// draws. Returns (entity, registered mesh_id). Fail-closed: a null
|
||||
/// renderer or an upload miss (0) throws — no silent hash-id draws.
|
||||
/// The caller decides skip-vs-fail via ENGINE_REQUIRE_GPU.
|
||||
/// </summary>
|
||||
public unsafe (ulong Entity, uint MeshId) DropModelUploaded(
|
||||
string assetPath, float groundX, float groundZ,
|
||||
Engine.Runtime.Renderer renderer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(renderer);
|
||||
var entity = DropModel(assetPath, groundX, groundZ);
|
||||
var full = Path.IsPathFullyQualified(assetPath) ? assetPath : Path.Combine(_assetsRoot, assetPath);
|
||||
var registered = renderer.UploadMesh(full);
|
||||
var world = _session.World;
|
||||
var table = Engine.Bindings.NativeApi.ApiLoader.Table;
|
||||
var mr = Engine.Runtime.Ecs.Get<Engine.Runtime.MeshRenderer>(table, world, new Engine.Runtime.Entity(entity));
|
||||
mr.MeshId = registered;
|
||||
Engine.Runtime.Ecs.Set(table, world, new Engine.Runtime.Entity(entity), in mr);
|
||||
return (entity, registered);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Double-click target: the scene path when <paramref name="entry"/>
|
||||
/// is a scene, else null. The shell owns the save prompt.
|
||||
|
||||
@@ -177,6 +177,36 @@ public sealed unsafe class Renderer : IDisposable
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #180: uploads the model at <paramref name="assetPath"/> (GLB/.gltf)
|
||||
/// into this renderer's MeshRegistry. Returns the registered mesh_id
|
||||
/// (>= 1). The viewport calls this right after
|
||||
/// <see cref="Engine.Editor.EditorSession.DropAsset"/> and rewrites the
|
||||
/// spawned MeshRenderer.MeshId with the id, so the drop draws (the
|
||||
/// spawn-time content hash never resolves in the registry). Fail-closed:
|
||||
/// 0 from native (missing file, no triangles, no device) throws here —
|
||||
/// the caller decides skip-vs-fail via ENGINE_REQUIRE_GPU.
|
||||
/// </summary>
|
||||
public uint UploadMesh(string assetPath)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentException.ThrowIfNullOrEmpty(assetPath);
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(assetPath + "\0");
|
||||
uint id;
|
||||
fixed (byte* p = bytes)
|
||||
{
|
||||
id = _table->EngineRendererUploadMesh(_renderer, p);
|
||||
}
|
||||
|
||||
if (id == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"renderer_upload_mesh({assetPath}) failed (missing file, no decodable triangles, or no Vulkan device).");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Captures the last presented frame to <paramref name="pngPath"/>.</summary>
|
||||
public void Screenshot(string pngPath)
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class AbiLayoutTests
|
||||
{
|
||||
var failures = 0;
|
||||
failures += Check(NativeApi.AbiVersionMajor == 4, "abi major == 4");
|
||||
failures += Check(NativeApi.AbiVersionMinor == 25, "abi minor == 25 (4.25: P3-05 inspector entity_remove tail after 4.24 field-metadata)");
|
||||
failures += Check(NativeApi.AbiVersionMinor == 26, "abi minor == 26 (4.26: #180 renderer_upload_mesh tail after 4.25 entity_remove)");
|
||||
failures += Check(NativeApi.AbiVersionPatch == 0, "abi patch == 0");
|
||||
failures += Check(NativeApi.ColumnStride == 64, "column stride == 64");
|
||||
|
||||
@@ -36,7 +36,7 @@ public static class AbiLayoutTests
|
||||
failures += Check(sizeof(NativeApi.WorldTransform) == 64, "WorldTransform 64B");
|
||||
failures += Check(sizeof(NativeApi.EcsChunkView) == 48, "EcsChunkView 48B");
|
||||
failures += Check(sizeof(NativeApi.FrameDispatch) == 4, "FrameDispatch 4B");
|
||||
failures += Check(sizeof(NativeApi.ApiTable) == 1288, "ApiTable 1288B (ABI 4.25: +1 entity_remove after 4.24 field-metadata tail)");
|
||||
failures += Check(sizeof(NativeApi.ApiTable) == 1296, "ApiTable 1296B (ABI 4.26: +1 renderer_upload_mesh after 4.25 entity_remove tail)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorDestroyEntity").ToInt64() == 672, "editor_destroy_entity @ 672 (ABI 4.10 tail)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorSnapshotRestoreSubtree").ToInt64() == 704, "snapshot_restore_subtree @ 704 (ABI 4.10 tail end)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineAudioCreate").ToInt64() == 712, "audio_create @ 712 (ABI 4.11 tail)");
|
||||
@@ -83,7 +83,8 @@ public static class AbiLayoutTests
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineComponentDescribe").ToInt64() == 1256, "component_describe @ 1256 (ABI 4.24 tail)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineComponentFieldCount").ToInt64() == 1264, "component_field_count @ 1264 (ABI 4.24 tail)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineComponentFieldAt").ToInt64() == 1272, "component_field_at @ 1272 (ABI 4.24 tail)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEntityRemove").ToInt64() == 1280, "entity_remove @ 1280 (ABI 4.25 tail end)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEntityRemove").ToInt64() == 1280, "entity_remove @ 1280 (ABI 4.25 tail)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineRendererUploadMesh").ToInt64() == 1288, "renderer_upload_mesh @ 1288 (ABI 4.26 tail end)");
|
||||
failures += Check(sizeof(NativeApi.EngineTimelinePlayer) == 8, "EngineTimelinePlayer opaque 8B");
|
||||
failures += Check(sizeof(NativeApi.TimelineActiveClip) == 16, "TimelineActiveClip 16B (P5-07 active row)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.TimelineActiveClip>("Clip").ToInt64() == 4, "TimelineActiveClip.Clip @ 4");
|
||||
|
||||
@@ -510,6 +510,10 @@ public static class EditorApiTests
|
||||
failures += Check(session.CountRoots() == rootsBefore + 1, "live: drop adds exactly one root");
|
||||
var pose = inspector.ReadPose(dropped);
|
||||
failures += Check(MathF.Abs(pose[0] - 1.5f) < 1e-6f && MathF.Abs(pose[1]) < 1e-6f && MathF.Abs(pose[2] + 2.5f) < 1e-6f, "live: drop lands on the ground point");
|
||||
// #180: viewport upload round-trip — drop -> renderer_upload_mesh
|
||||
// -> rewrite MeshRenderer with the registered id. GPU-gated
|
||||
// (fail-closed without a device, like RunPickGizmoLive).
|
||||
failures += RunDropUploadLive(table, world, session, browser, Path.Combine(assetsDir, "cube.glb"), dropped);
|
||||
failures += Check(inspector.Undo(), "live: drop undo succeeds (one step)");
|
||||
failures += Check(session.CountRoots() == rootsBefore, "live: undo removes the dropped entity");
|
||||
failures += Check(inspector.Redo(), "live: drop redo succeeds");
|
||||
@@ -1378,6 +1382,58 @@ public static class EditorApiTests
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
// #180 live: drop -> renderer_upload_mesh -> rewrite MeshRenderer with
|
||||
// the registered id -> registry-resolvable (fails closed without a GPU).
|
||||
private static unsafe int RunDropUploadLive(
|
||||
NativeApi.ApiTable* table,
|
||||
NativeApi.EngineWorld* world,
|
||||
EditorSession session,
|
||||
ProjectBrowser browser,
|
||||
string glbPath,
|
||||
ulong droppedEntity)
|
||||
{
|
||||
var requireGpu = string.Equals(
|
||||
Environment.GetEnvironmentVariable("ENGINE_REQUIRE_GPU"), "1",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
Engine.Runtime.Renderer? renderer = null;
|
||||
try
|
||||
{
|
||||
renderer = Engine.Runtime.Renderer.CreateOffscreen(table, world, 320, 240);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Console.WriteLine($"[{(requireGpu ? "FAIL" : "SKIP")}] drop-upload: no offscreen renderer ({ex.Message})");
|
||||
return requireGpu ? 1 : 0;
|
||||
}
|
||||
|
||||
using (renderer)
|
||||
{
|
||||
uint registered;
|
||||
try
|
||||
{
|
||||
registered = renderer.UploadMesh(glbPath);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Console.WriteLine($"[{(requireGpu ? "FAIL" : "SKIP")}] drop-upload: no GPU upload path ({ex.Message})");
|
||||
return requireGpu ? 1 : 0;
|
||||
}
|
||||
|
||||
var failures = 0;
|
||||
failures += Check(registered != 0, "live: upload returns registered mesh_id");
|
||||
var before = Engine.Runtime.Ecs.Get<Engine.Runtime.MeshRenderer>(table, world, new Engine.Runtime.Entity(droppedEntity));
|
||||
failures += Check(before.MeshId != registered, "live: spawn-time hash differs from registered id (#180)");
|
||||
var rewritten = before;
|
||||
rewritten.MeshId = registered;
|
||||
Engine.Runtime.Ecs.Set(table, world, new Engine.Runtime.Entity(droppedEntity), in rewritten);
|
||||
var after = Engine.Runtime.Ecs.Get<Engine.Runtime.MeshRenderer>(table, world, new Engine.Runtime.Entity(droppedEntity));
|
||||
failures += Check(after.MeshId == registered && after.MeshId != 0, "live: rewritten mesh_id is the registered id");
|
||||
failures += Check(table->EngineRendererUploadMesh(renderer.NativeHandle, (byte*)null) == 0, "live: upload null path -> 0");
|
||||
return failures;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal valid GLB bytes for the staged drop fixture. Canonical
|
||||
/// generator: native/tests/minimal_glb_fixture.hpp (WriteMinimalGlb,
|
||||
|
||||
@@ -231,7 +231,7 @@ extern "C" {
|
||||
// order + signature; new fields extend the tail only.
|
||||
// ---------------------------------------------------------------------------
|
||||
#define ENGINE_ABI_VERSION_MAJOR 4
|
||||
#define ENGINE_ABI_VERSION_MINOR 25
|
||||
#define ENGINE_ABI_VERSION_MINOR 26
|
||||
#define ENGINE_ABI_VERSION_PATCH 0
|
||||
|
||||
// 64-byte cache-line pitch for the component columns handed to managed
|
||||
@@ -1536,6 +1536,16 @@ typedef int32_t (*EngineEntityGetFn)(EngineWorld* world, uint64_t entity,
|
||||
typedef int32_t (*EngineEntityRemoveFn)(EngineWorld* world, uint64_t entity,
|
||||
uint64_t component);
|
||||
|
||||
// Upload the model at `asset_path` into `renderer` and return its
|
||||
// registered mesh_id (>= 1). `asset_path` is a NUL-terminated UTF-8 path
|
||||
// owned by the caller; the renderer owns the uploaded GpuMesh. Returns 0
|
||||
// on failure (null renderer/path, missing file, no decodable triangles,
|
||||
// no Vulkan device, upload failure). #180: the viewport uploads the
|
||||
// dropped model and rewrites MeshRenderer.mesh_id with this id (the
|
||||
// spawn-time content hash never resolves in the registry).
|
||||
typedef uint32_t (*EngineRendererUploadMeshFn)(EngineRenderer* renderer,
|
||||
const char* asset_path);
|
||||
|
||||
// Register a system over `term_count` component ids (1..8) with one
|
||||
// access flag per term. Returns the system index (>= 0) or an
|
||||
// ENGINE_ABI_ERR_* code. `name` is copied (truncated to 63 bytes).
|
||||
@@ -1955,6 +1965,17 @@ typedef struct EngineApiTable {
|
||||
EngineComponentFieldAtFn component_field_at;
|
||||
// P3-05 inspector tail (ABI 4.25). All earlier order frozen.
|
||||
EngineEntityRemoveFn entity_remove;
|
||||
// #180 model-upload tail (ABI 4.26). All earlier order frozen.
|
||||
//
|
||||
// Uploads the model at `asset_path` (GLB/.gltf, NUL-terminated UTF-8,
|
||||
// caller-owned) into `renderer`'s MeshRegistry and returns the
|
||||
// registered mesh_id (>= 1; 0 is reserved/invalid). The viewport calls
|
||||
// it right after project_drop_asset and rewrites the spawned
|
||||
// MeshRenderer.mesh_id with the registered id, so the drop draws
|
||||
// (the spawn-time hash never resolves in the registry). Returns 0 on
|
||||
// failure (null renderer/path, missing file, no decodable triangles,
|
||||
// no Vulkan device, upload failure).
|
||||
EngineRendererUploadMeshFn renderer_upload_mesh;
|
||||
|
||||
} EngineApiTable;
|
||||
|
||||
|
||||
@@ -2798,6 +2798,10 @@ auto ApiRendererRenderToBuffer(EngineRenderer* renderer, std::uint8_t* rgba_out,
|
||||
auto ApiRendererPick(EngineRenderer* renderer, std::uint32_t x,
|
||||
std::uint32_t y, const float* view_proj16) noexcept
|
||||
-> std::uint64_t;
|
||||
// --- #180 model-upload tail (ABI 4.26; definition in renderer_bridge.cpp,
|
||||
// which owns RendererImpl, the mesh registry, and the cgltf include) ------
|
||||
auto ApiRendererUploadMesh(EngineRenderer* renderer,
|
||||
const char* asset_path) noexcept -> std::uint32_t;
|
||||
|
||||
auto ApiRendererGpuTime(EngineRenderer* renderer) noexcept -> float;
|
||||
// P5-04-followup: narrow HUD setter (definition in renderer_bridge.cpp,
|
||||
@@ -3726,6 +3730,7 @@ const EngineApiTable kApiTable = {
|
||||
&ApiComponentFieldCount,
|
||||
&ApiComponentFieldAt,
|
||||
&ApiEntityRemove,
|
||||
&engine::bridge::ApiRendererUploadMesh,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "engine/bridge/engine_abi.h"
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -33,6 +34,7 @@
|
||||
|
||||
#include "engine/ecs/ecs_render_components.hpp"
|
||||
#include "engine/ecs/transform.hpp"
|
||||
#include "engine/assets/asset_mmap.hpp"
|
||||
#include "engine/renderer/forward_renderer.hpp"
|
||||
#include "engine/renderer/frame_gpu_timer.hpp"
|
||||
#include "engine/renderer/gpu_mesh.hpp"
|
||||
@@ -791,6 +793,293 @@ auto ApiRendererMeshCreate(EngineRenderer* renderer, const float* vertices,
|
||||
#endif
|
||||
}
|
||||
|
||||
// --- #180 model-upload tail (ABI 4.26) --------------------------------------
|
||||
//
|
||||
// Parses `asset_path` (GLB/.gltf) with cgltf, decodes POSITION (+ NORMAL +
|
||||
// TEXCOORD_0 when present), and uploads one interleaved MeshVertex buffer
|
||||
// through the same GpuMesh path as renderer_mesh_create. NORMAL is flat
|
||||
// shaded (face normal) when absent; uv defaults to (0,0). Non-triangle
|
||||
// primitives and accessors with the wrong type are skipped; an asset with
|
||||
// no decodable triangles fails closed (0). Returns the registered
|
||||
// mesh_id (>= 1; 0 = null args, missing file, no triangles, no device,
|
||||
// upload failure).
|
||||
//
|
||||
// cgltf include: the TU already compiles with ENGINE_ASSETS_HAS_CGLTF + the
|
||||
// include dir (PUBLIC usage from engine_core_assets), and implements the
|
||||
// header here (asset_importer.cpp is the other implementation TU —
|
||||
// module-split so this one serves the viewport without an assets link).
|
||||
#if defined(ENGINE_ASSETS_HAS_CGLTF)
|
||||
#if defined(__clang__) || defined(__GNUC__)
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wsign-conversion"
|
||||
#pragma GCC diagnostic ignored "-Wconversion"
|
||||
#pragma GCC diagnostic ignored "-Wunused-function"
|
||||
#elif defined(_MSC_VER)
|
||||
#pragma warning(push)
|
||||
#pragma warning(disable : 4996)
|
||||
#endif
|
||||
// NOTE: no CGLTF_IMPLEMENTATION here — asset_importer.cpp is the single
|
||||
// implementation TU (dual definitions break the engine_core link).
|
||||
#include "cgltf.h"
|
||||
#if defined(__clang__) || defined(__GNUC__)
|
||||
#pragma GCC diagnostic pop
|
||||
#elif defined(_MSC_VER)
|
||||
#pragma warning(pop)
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
// #180: one decoded primitive (positions required; normal/uv optional).
|
||||
// Counts follow the cgltf accessor counts (u32 each; the viewport path
|
||||
// rejects > 4M verts/indices before allocating — fail closed on corrupt
|
||||
// count fields, same bound as the background lane).
|
||||
struct UploadModelArrays {
|
||||
std::vector<float> pos; // xyz packed across all primitives
|
||||
std::vector<float> nrm; // xyz packed (empty when NORMAL absent)
|
||||
std::vector<float> uv; // uv packed (empty when TEXCOORD_0 absent)
|
||||
std::vector<std::uint32_t> idx;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
auto ApiRendererUploadMesh(EngineRenderer* renderer,
|
||||
const char* asset_path) noexcept -> std::uint32_t {
|
||||
if (renderer == nullptr || asset_path == nullptr || asset_path[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
#if !defined(ENGINE_RENDERER_HAS_VULKAN) || !defined(ENGINE_ASSETS_HAS_CGLTF)
|
||||
(void)renderer;
|
||||
(void)asset_path;
|
||||
return 0;
|
||||
#else
|
||||
// Map the file (read-only; no copy — the mapped bytes feed cgltf_parse
|
||||
// directly, same as the M5-01 fast lane). MappedFile (not fopen) so
|
||||
// MSVC /WX stays clean (fopen is C4996 under /WX; asset_mmap wraps the
|
||||
// platform map + holds the handle on Windows).
|
||||
engine::assets::MappedFile mf{};
|
||||
if (!mf.map(asset_path) || !mf.valid() || mf.size() == 0 ||
|
||||
mf.size() > 256u * 1024u * 1024u) {
|
||||
return 0;
|
||||
}
|
||||
const std::uint8_t* bytes = mf.data();
|
||||
const std::size_t size = mf.size();
|
||||
// Parse (GLB magic or .gltf JSON — cgltf_parse handles both from the
|
||||
// mapped bytes; load_buffers resolves external .bin next to the file).
|
||||
cgltf_options opts{};
|
||||
std::memset(&opts, 0, sizeof(opts));
|
||||
cgltf_data* data = nullptr;
|
||||
if (cgltf_parse(&opts, bytes, size, &data) !=
|
||||
cgltf_result_success ||
|
||||
data == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
if (cgltf_load_buffers(&opts, data, asset_path) != cgltf_result_success) {
|
||||
cgltf_free(data);
|
||||
return 0;
|
||||
}
|
||||
// Decode POSITION (+ NORMAL + TEXCOORD_0 when present) across all
|
||||
// triangle primitives; everything else is skipped (same rule as the
|
||||
// background lane: wrong-type accessors never emit zeroed verts).
|
||||
UploadModelArrays model{};
|
||||
std::uint32_t base = 0;
|
||||
for (std::size_t mi = 0; mi < data->meshes_count; ++mi) {
|
||||
const cgltf_mesh& mesh = data->meshes[mi];
|
||||
for (std::size_t pi = 0; pi < mesh.primitives_count; ++pi) {
|
||||
const cgltf_primitive& prim = mesh.primitives[pi];
|
||||
if (prim.type != cgltf_primitive_type_triangles) {
|
||||
continue;
|
||||
}
|
||||
const cgltf_accessor* pos = nullptr;
|
||||
const cgltf_accessor* nrm = nullptr;
|
||||
const cgltf_accessor* uv = nullptr;
|
||||
for (std::size_t ai = 0; ai < prim.attributes_count; ++ai) {
|
||||
const char* name = prim.attributes[ai].name;
|
||||
if (name == nullptr) {
|
||||
continue;
|
||||
}
|
||||
if (std::strcmp(name, "POSITION") == 0) {
|
||||
pos = prim.attributes[ai].data;
|
||||
} else if (std::strcmp(name, "NORMAL") == 0) {
|
||||
nrm = prim.attributes[ai].data;
|
||||
} else if (std::strcmp(name, "TEXCOORD_0") == 0) {
|
||||
uv = prim.attributes[ai].data;
|
||||
}
|
||||
}
|
||||
if (pos == nullptr || pos->type != cgltf_type_vec3 ||
|
||||
pos->component_type != cgltf_component_type_r_32f ||
|
||||
pos->count == 0 || pos->count > 4000000) {
|
||||
continue;
|
||||
}
|
||||
const std::size_t vstart = model.pos.size() / 3;
|
||||
const std::size_t vcount = pos->count;
|
||||
model.pos.resize((vstart + vcount) * 3);
|
||||
bool pos_ok = true;
|
||||
for (std::size_t vi = 0; vi < vcount; ++vi) {
|
||||
float xyz[3] = {0, 0, 0};
|
||||
if (!cgltf_accessor_read_float(pos, vi, xyz, 3)) {
|
||||
pos_ok = false;
|
||||
break;
|
||||
}
|
||||
model.pos[(vstart + vi) * 3 + 0] = xyz[0];
|
||||
model.pos[(vstart + vi) * 3 + 1] = xyz[1];
|
||||
model.pos[(vstart + vi) * 3 + 2] = xyz[2];
|
||||
}
|
||||
if (!pos_ok) {
|
||||
model.pos.resize(vstart * 3);
|
||||
continue;
|
||||
}
|
||||
// NORMAL optional: read when the accessor is the right shape, else
|
||||
// leave the lane empty (flat shaded below).
|
||||
bool have_nrm = false;
|
||||
if (nrm != nullptr && nrm->type == cgltf_type_vec3 &&
|
||||
nrm->component_type == cgltf_component_type_r_32f &&
|
||||
nrm->count == vcount) {
|
||||
const std::size_t nbase = model.nrm.size();
|
||||
model.nrm.resize(nbase + vcount * 3);
|
||||
have_nrm = true;
|
||||
for (std::size_t vi = 0; vi < vcount; ++vi) {
|
||||
float xyz[3] = {0, 0, 0};
|
||||
if (!cgltf_accessor_read_float(nrm, vi, xyz, 3)) {
|
||||
have_nrm = false;
|
||||
break;
|
||||
}
|
||||
model.nrm[(nbase + vi * 3) + 0] = xyz[0];
|
||||
model.nrm[(nbase + vi * 3) + 1] = xyz[1];
|
||||
model.nrm[(nbase + vi * 3) + 2] = xyz[2];
|
||||
}
|
||||
if (!have_nrm) {
|
||||
model.nrm.resize(nbase);
|
||||
}
|
||||
}
|
||||
// TEXCOORD_0 optional: same partial-read rule as NORMAL.
|
||||
bool have_uv = false;
|
||||
if (uv != nullptr && uv->type == cgltf_type_vec2 &&
|
||||
uv->component_type == cgltf_component_type_r_32f &&
|
||||
uv->count == vcount) {
|
||||
const std::size_t ubase = model.uv.size();
|
||||
model.uv.resize(ubase + vcount * 2);
|
||||
have_uv = true;
|
||||
for (std::size_t vi = 0; vi < vcount; ++vi) {
|
||||
float st[2] = {0, 0};
|
||||
if (!cgltf_accessor_read_float(uv, vi, st, 2)) {
|
||||
have_uv = false;
|
||||
break;
|
||||
}
|
||||
model.uv[(ubase + vi * 2) + 0] = st[0];
|
||||
model.uv[(ubase + vi * 2) + 1] = st[1];
|
||||
}
|
||||
if (!have_uv) {
|
||||
model.uv.resize(ubase);
|
||||
}
|
||||
}
|
||||
// Partial NORMAL/UV lanes (earlier primitive had them, this one
|
||||
// does not) pad with zeros so the lanes stay parallel to pos.
|
||||
if (!model.nrm.empty() && !have_nrm) {
|
||||
model.nrm.resize(model.nrm.size() + vcount * 3, 0.0F);
|
||||
}
|
||||
if (!model.uv.empty() && !have_uv) {
|
||||
model.uv.resize(model.uv.size() + vcount * 2, 0.0F);
|
||||
}
|
||||
if (prim.indices != nullptr) {
|
||||
if (prim.indices->count == 0 || prim.indices->count > 12000000) {
|
||||
continue;
|
||||
}
|
||||
for (std::size_t ii = 0; ii < prim.indices->count; ++ii) {
|
||||
model.idx.push_back(
|
||||
base +
|
||||
static_cast<std::uint32_t>(
|
||||
cgltf_accessor_read_index(prim.indices, ii)));
|
||||
}
|
||||
} else {
|
||||
for (std::size_t vi = 0; vi < vcount; ++vi) {
|
||||
model.idx.push_back(base + static_cast<std::uint32_t>(vi));
|
||||
}
|
||||
}
|
||||
base += static_cast<std::uint32_t>(vcount);
|
||||
}
|
||||
}
|
||||
cgltf_free(data);
|
||||
if (model.pos.empty() || model.idx.empty()) {
|
||||
return 0;
|
||||
}
|
||||
// Interleave to MeshVertex (pos[3], normal[3], uv[2]): NORMAL absent ->
|
||||
// face normal per triangle (flat shaded, correct for the unindexed
|
||||
// minimal fixture); uv absent -> (0,0).
|
||||
const std::size_t vcount = model.pos.size() / 3;
|
||||
const bool have_nrm = model.nrm.size() == vcount * 3;
|
||||
const bool have_uv = model.uv.size() == vcount * 2;
|
||||
std::vector<MeshVertex> verts(vcount);
|
||||
for (std::size_t i = 0; i < vcount; ++i) {
|
||||
verts[i].pos[0] = model.pos[i * 3 + 0];
|
||||
verts[i].pos[1] = model.pos[i * 3 + 1];
|
||||
verts[i].pos[2] = model.pos[i * 3 + 2];
|
||||
if (have_nrm) {
|
||||
verts[i].normal[0] = model.nrm[i * 3 + 0];
|
||||
verts[i].normal[1] = model.nrm[i * 3 + 1];
|
||||
verts[i].normal[2] = model.nrm[i * 3 + 2];
|
||||
}
|
||||
if (have_uv) {
|
||||
verts[i].uv[0] = model.uv[i * 2 + 0];
|
||||
verts[i].uv[1] = model.uv[i * 2 + 1];
|
||||
}
|
||||
}
|
||||
if (!have_nrm) {
|
||||
for (std::size_t t = 0; t + 2 < model.idx.size(); t += 3) {
|
||||
const std::uint32_t a = model.idx[t];
|
||||
const std::uint32_t b = model.idx[t + 1];
|
||||
const std::uint32_t c = model.idx[t + 2];
|
||||
if (a >= vcount || b >= vcount || c >= vcount) {
|
||||
return 0;
|
||||
}
|
||||
const float abx = verts[b].pos[0] - verts[a].pos[0];
|
||||
const float aby = verts[b].pos[1] - verts[a].pos[1];
|
||||
const float abz = verts[b].pos[2] - verts[a].pos[2];
|
||||
const float acx = verts[c].pos[0] - verts[a].pos[0];
|
||||
const float acy = verts[c].pos[1] - verts[a].pos[1];
|
||||
const float acz = verts[c].pos[2] - verts[a].pos[2];
|
||||
float nx = aby * acz - abz * acy;
|
||||
float ny = abz * acx - abx * acz;
|
||||
float nz = abx * acy - aby * acx;
|
||||
const float len = nx * nx + ny * ny + nz * nz;
|
||||
if (len > 1e-12F) {
|
||||
const float inv = 1.0F / std::sqrt(len);
|
||||
nx *= inv;
|
||||
ny *= inv;
|
||||
nz *= inv;
|
||||
}
|
||||
verts[a].normal[0] = nx;
|
||||
verts[a].normal[1] = ny;
|
||||
verts[a].normal[2] = nz;
|
||||
verts[b].normal[0] = nx;
|
||||
verts[b].normal[1] = ny;
|
||||
verts[b].normal[2] = nz;
|
||||
verts[c].normal[0] = nx;
|
||||
verts[c].normal[1] = ny;
|
||||
verts[c].normal[2] = nz;
|
||||
}
|
||||
}
|
||||
auto* impl = ImplOf(renderer);
|
||||
auto mesh = std::unique_ptr<GpuMesh>(new (std::nothrow) GpuMesh());
|
||||
if (mesh == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
if (!mesh
|
||||
->create(impl->device, verts.data(),
|
||||
static_cast<std::uint32_t>(vcount), model.idx.data(),
|
||||
static_cast<std::uint32_t>(model.idx.size()))
|
||||
.ok()) {
|
||||
return 0;
|
||||
}
|
||||
const std::uint32_t id = impl->registry.add(mesh.get());
|
||||
if (id == 0) {
|
||||
return 0;
|
||||
}
|
||||
impl->meshes.push_back(std::move(mesh));
|
||||
return id;
|
||||
#endif
|
||||
}
|
||||
|
||||
auto ApiRendererScreenshot(EngineRenderer* renderer,
|
||||
const char* png_path) noexcept -> int32_t {
|
||||
if (renderer == nullptr || png_path == nullptr || png_path[0] == '\0') {
|
||||
|
||||
@@ -345,9 +345,18 @@ auto main() -> int {
|
||||
failures += check(offsetof(EcsChunkView, world) == 8, "abi: EcsChunkView.world @ 8");
|
||||
failures += check(offsetof(EcsChunkView, entities) == 16, "abi: EcsChunkView.entities @ 16");
|
||||
failures += check(offsetof(EcsChunkView, user_data) == 40, "abi: EcsChunkView.user_data @ 40");
|
||||
failures += check(sizeof(EngineApiTable) == 1288, "abi: EngineApiTable 1288B (ABI 4.25 inspector tail)");
|
||||
failures += check(sizeof(EngineApiTable) == 1296, "abi: EngineApiTable 1296B (ABI 4.26 upload tail)");
|
||||
failures += check(offsetof(EngineApiTable, entity_remove) == 1280,
|
||||
"abi: entity_remove @ 1280 (ABI 4.25 inspector tail end)");
|
||||
"abi: entity_remove @ 1280 (ABI 4.25 inspector tail)");
|
||||
failures += check(offsetof(EngineApiTable, renderer_upload_mesh) == 1288,
|
||||
"abi: renderer_upload_mesh @ 1288 (ABI 4.26 upload tail end)");
|
||||
failures += check(api->renderer_upload_mesh != nullptr, "abi: renderer_upload_mesh set");
|
||||
if (api->renderer_upload_mesh != nullptr) {
|
||||
failures += check(api->renderer_upload_mesh(nullptr, nullptr) == 0,
|
||||
"abi: renderer_upload_mesh null args -> 0");
|
||||
} else {
|
||||
failures += check(false, "abi: renderer_upload_mesh null args -> 0");
|
||||
}
|
||||
failures += check(sizeof(EngineInputState) == 1668, "abi: EngineInputState 1668B");
|
||||
failures += check(offsetof(EngineInputState, mouse_x) == 1536, "abi: EngineInputState.mouse_x @ 1536");
|
||||
failures += check(offsetof(EngineInputState, mouse_buttons) == 1552, "abi: EngineInputState.mouse_buttons @ 1552");
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
// visible in one frame — the spawned MeshRenderer row carries the id
|
||||
// the forward path keys draws off, so no second tick is needed).
|
||||
//
|
||||
// #180 follow-up (ABI 4.26): the viewport uploads the dropped model with
|
||||
// renderer_upload_mesh and rewrites mesh_id with the registered id.
|
||||
// The second half of this test does that round-trip: drop -> upload via
|
||||
// the ABI renderer tail -> registry.find(mesh_id) resolves. GPU-gated
|
||||
// (ENGINE_REQUIRE_GPU=1 fails closed without a device, like every GPU
|
||||
// test); the SDK-free build keeps the first half green.
|
||||
//
|
||||
// Fixture: the minimal valid GLB is synthesized in a scratch dir (same
|
||||
// generator as test_asset_pipeline.cpp: JSON with 1 mesh / 1 primitive /
|
||||
// 2 nodes + BIN chunk), so no binaries are checked in and no network is
|
||||
@@ -23,11 +30,13 @@
|
||||
#include <string>
|
||||
|
||||
#include "minimal_glb_fixture.hpp"
|
||||
#include "engine/bridge/engine_abi.h"
|
||||
#include "engine/core/job_scheduler.hpp"
|
||||
#include "engine/ecs/ecs_world.hpp"
|
||||
#include "engine/ecs/editor_bridge.hpp"
|
||||
#include "engine/ecs/transform.hpp"
|
||||
#include "flecs.h"
|
||||
#include "gpu_required.hpp"
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
@@ -187,6 +196,69 @@ auto main() -> int {
|
||||
check(session.count_roots(&final_roots) == 0 && final_roots == after,
|
||||
"drop: one root removed by undo");
|
||||
|
||||
// --- #180: viewport upload round-trip ------------------------------------
|
||||
// drop -> renderer_upload_mesh(glb) -> rewrite MeshRenderer.mesh_id ->
|
||||
// registry.find(mesh_id) resolves. GPU-gated: without a Vulkan device
|
||||
// the upload path fails closed (0) and the test SKIPs unless
|
||||
// ENGINE_REQUIRE_GPU=1 (then it FAILs, like every GPU test).
|
||||
{
|
||||
const EngineApiTable* api = engine_get_api_table();
|
||||
check(api != nullptr, "drop: api table non-null");
|
||||
check(api->renderer_upload_mesh != nullptr, "drop: renderer_upload_mesh set");
|
||||
if (api != nullptr && api->renderer_upload_mesh != nullptr) {
|
||||
check(api->renderer_upload_mesh(nullptr, nullptr) == 0,
|
||||
"drop: upload null args -> 0");
|
||||
EngineWorld* aworld = api->world_create();
|
||||
check(aworld != nullptr, "drop: abi world_create");
|
||||
if (aworld != nullptr) {
|
||||
// Offscreen renderer borrows the ABI world (no window).
|
||||
EngineRenderer* renderer = nullptr;
|
||||
if (api->renderer_create_offscreen != nullptr) {
|
||||
renderer = api->renderer_create_offscreen(aworld, 320, 240);
|
||||
}
|
||||
if (renderer == nullptr) {
|
||||
check(gpu_missing("drop: no offscreen renderer (no Vulkan device)") == 0,
|
||||
"drop: upload skipped without GPU");
|
||||
} else {
|
||||
// Absolute path: the bridge parses from disk (relative would
|
||||
// depend on the test's cwd, which ctest controls).
|
||||
const std::string abs_glb =
|
||||
(fs::current_path(ec) / glb).string();
|
||||
const std::uint32_t up_id =
|
||||
api->renderer_upload_mesh(renderer, abs_glb.c_str());
|
||||
check(up_id != 0, "drop: upload returns registered mesh_id");
|
||||
check(up_id == 1, "drop: first upload is registry id 1");
|
||||
check(api->renderer_upload_mesh(renderer, abs_glb.c_str()) == 2,
|
||||
"drop: second upload is registry id 2 (sequential)");
|
||||
check(api->renderer_upload_mesh(renderer,
|
||||
(assets + "/missing.glb").c_str()) == 0,
|
||||
"drop: upload missing file -> 0");
|
||||
// Rewrite the SPAWNED entity's MeshRenderer.mesh_id with the
|
||||
// registered id (the viewport path): drop's content hash never
|
||||
// resolves; the registered id must.
|
||||
ecs_entity_t upload_comp = ecs_lookup_path_w_sep(
|
||||
world.raw(), 0, "Engine.Runtime.MeshRenderer", "", "", false);
|
||||
check(upload_comp != 0, "drop: upload MeshRenderer resolves");
|
||||
if (upload_comp != 0) {
|
||||
auto* mrw = static_cast<engine::ecs::MeshRenderer*>(ecs_get_mut_id(
|
||||
world.raw(), static_cast<ecs_entity_t>(entity2), upload_comp));
|
||||
check(mrw != nullptr, "drop: upload MeshRenderer row mutable");
|
||||
if (mrw != nullptr) {
|
||||
mrw->mesh_id = up_id;
|
||||
}
|
||||
const auto* mrr = static_cast<const engine::ecs::MeshRenderer*>(
|
||||
ecs_get_id(world.raw(), static_cast<ecs_entity_t>(entity2),
|
||||
upload_comp));
|
||||
check(mrr != nullptr && mrr->mesh_id == up_id && mrr->mesh_id != 0,
|
||||
"drop: rewritten mesh_id is the registered id");
|
||||
}
|
||||
api->renderer_destroy(renderer);
|
||||
}
|
||||
api->world_destroy(aworld);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs::remove_all(root, ec);
|
||||
std::printf(failures == 0 ? "PROJECT_DROP_OK\n" : "PROJECT_DROP_FAILED\n");
|
||||
return failures == 0 ? 0 : 1;
|
||||
|
||||
Reference in new issue
Block a user