summaryrefslogtreecommitdiff
path: root/src/tests/3d
diff options
context:
space:
mode:
Diffstat (limited to 'src/tests/3d')
-rw-r--r--src/tests/3d/test_3d.cc126
-rw-r--r--src/tests/3d/test_3d_physics.cc296
-rw-r--r--src/tests/3d/test_3d_render.cc326
-rw-r--r--src/tests/3d/test_mesh.cc425
-rw-r--r--src/tests/3d/test_physics.cc150
-rw-r--r--src/tests/3d/test_scene_loader.cc134
6 files changed, 1457 insertions, 0 deletions
diff --git a/src/tests/3d/test_3d.cc b/src/tests/3d/test_3d.cc
new file mode 100644
index 0000000..e0fb2e0
--- /dev/null
+++ b/src/tests/3d/test_3d.cc
@@ -0,0 +1,126 @@
+// This file is part of the 64k demo project.
+// It tests the 3D system components (Camera, Object, Scene).
+
+#include "3d/camera.h"
+#include "3d/object.h"
+#include "3d/scene.h"
+#include <cassert>
+#include <cmath>
+#include <iostream>
+
+bool near(float a, float b, float e = 0.001f) {
+ return std::abs(a - b) < e;
+}
+
+void test_camera() {
+ std::cout << "Testing Camera..." << std::endl;
+ Camera cam;
+ cam.position = vec3(0, 0, 10);
+ cam.target = vec3(0, 0, 0);
+
+ mat4 view = cam.get_view_matrix();
+ // Camera at (0,0,10) looking at (0,0,0). World (0,0,0) -> View (0,0,-10)
+ assert(near(view.m[14], -10.0f));
+
+ // Test Camera::set_look_at
+ cam.set_look_at({5, 0, 0}, {0, 0, 0},
+ {0, 1, 0}); // Look at origin from (5,0,0)
+ mat4 view_shifted = cam.get_view_matrix();
+ // The camera's forward vector (0,0,-1) should now point towards (-1,0,0) in
+ // world space. The translation part of the view matrix should be based on
+ // -dot(s, eye), -dot(u, eye), dot(f, eye) s = (0,0,-1), u = (0,1,0), f =
+ // (-1,0,0) m[12] = -dot({0,0,-1}, {5,0,0}) = 0 m[13] = -dot({0,1,0}, {5,0,0})
+ // = 0 m[14] = dot({-1,0,0}, {5,0,0}) = -5
+ assert(near(view_shifted.m[12], 0.0f));
+ assert(near(view_shifted.m[13], 0.0f));
+ assert(near(view_shifted.m[14], -5.0f));
+
+ // Test Camera::get_projection_matrix with varied parameters
+ // Change FOV and aspect ratio
+ mat4 proj = cam.get_projection_matrix();
+ cam.fov_y_rad = 1.0472f; // 60 degrees
+ cam.aspect_ratio = 0.5f; // Narrower aspect ratio
+ mat4 proj_varied = cam.get_projection_matrix();
+ // m[0] should increase due to narrower aspect ratio (1/tan(30deg)/0.5)
+ assert(proj_varied.m[0] > proj.m[0]);
+ // m[5] should increase due to larger FOV (1/tan(30deg))
+ assert(proj_varied.m[5] < proj.m[5]);
+}
+
+void test_object_transform() {
+ std::cout << "Testing Object Transform..." << std::endl;
+ Object3D obj;
+ obj.position = vec3(10, 0, 0);
+
+ // Model matrix should translate by (10,0,0)
+ mat4 m = obj.get_model_matrix();
+ assert(near(m.m[12], 10.0f));
+
+ // Test composed transformations (translate then rotate)
+ obj.position = vec3(5, 0, 0);
+ obj.rotation = quat::from_axis({0, 1, 0}, 1.570796f); // 90 deg Y rotation
+ m = obj.get_model_matrix();
+
+ // Transform point (1,0,0). Rotation around Y maps (1,0,0) to (0,0,-1).
+ // Translation moves it by (5,0,0). Final world pos: (5,0,-1).
+ vec4 p_comp(1, 0, 0, 1);
+ vec4 res_comp = m * p_comp;
+ assert(near(res_comp.x, 5.0f));
+ assert(near(res_comp.z, -1.0f));
+
+ // Test Object3D::inv_model calculation
+ // Model matrix for translation (5,0,0) is just translation
+ obj.position = vec3(5, 0, 0);
+ obj.rotation = quat(); // Identity rotation
+ mat4 model_t = obj.get_model_matrix();
+ mat4 inv_model_t = model_t.inverse();
+ // Applying inv_model to a translated point should undo the translation.
+ // Point (5,0,0) should go to (0,0,0)
+ vec4 translated_point(5, 0, 0, 1);
+ vec4 original_space_t =
+ inv_model_t *
+ vec4(translated_point.x, translated_point.y, translated_point.z, 1.0);
+ assert(near(original_space_t.x, 0.0f) && near(original_space_t.y, 0.0f) &&
+ near(original_space_t.z, 0.0f));
+
+ // Model matrix with rotation (90 deg Y) and translation (5,0,0)
+ obj.position = vec3(5, 0, 0);
+ obj.rotation = quat::from_axis({0, 1, 0}, 1.570796f);
+ mat4 model_trs = obj.get_model_matrix();
+ mat4 inv_model_trs = model_trs.inverse();
+ // Transform point (1,0,0) (local right) via TRS: Rotates to (0,0,-1),
+ // Translates to (5,0,-1)
+ vec4 p_trs(1, 0, 0, 1);
+ vec4 transformed_p = model_trs * p_trs;
+ assert(near(transformed_p.x, 5.0f) && near(transformed_p.z, -1.0f));
+ // Apply inverse to transformed point to get back original point
+ vec4 original_space_trs = inv_model_trs * transformed_p;
+ assert(near(original_space_trs.x, 1.0f) && near(original_space_trs.y, 0.0f) &&
+ near(original_space_trs.z, 0.0f));
+}
+
+void test_scene() {
+ std::cout << "Testing Scene..." << std::endl;
+ Scene scene;
+ scene.add_object(Object3D());
+ assert(scene.objects.size() == 1);
+ scene.clear();
+ assert(scene.objects.empty());
+
+ // Add multiple objects and check count
+ scene.add_object(Object3D());
+ scene.add_object(Object3D());
+ assert(scene.objects.size() == 2);
+
+ // Test clearing the scene
+ scene.clear();
+ assert(scene.objects.empty());
+}
+
+int main() {
+ test_camera();
+ test_object_transform();
+ test_scene();
+ std::cout << "--- 3D SYSTEM TESTS PASSED ---" << std::endl;
+ return 0;
+}
diff --git a/src/tests/3d/test_3d_physics.cc b/src/tests/3d/test_3d_physics.cc
new file mode 100644
index 0000000..eb1f5ef
--- /dev/null
+++ b/src/tests/3d/test_3d_physics.cc
@@ -0,0 +1,296 @@
+// This file is part of the 64k demo project.
+// Standalone "mini-demo" for testing the 3D physics engine.
+
+#include "3d/bvh.h"
+#include "3d/camera.h"
+#include "3d/object.h"
+#include "3d/physics.h"
+#include "3d/renderer.h"
+#include "3d/scene.h"
+#include "gpu/effects/shaders.h"
+#include "gpu/texture_manager.h"
+#include "platform/platform.h"
+#include "procedural/generator.h"
+#include <cmath>
+#include <cstdio>
+#include <cstring>
+#include <vector>
+
+// Global State
+static Renderer3D g_renderer;
+static TextureManager g_textures;
+static Scene g_scene;
+static Camera g_camera;
+static PhysicsSystem g_physics;
+static WGPUDevice g_device = nullptr;
+static WGPUQueue g_queue = nullptr;
+static WGPUSurface g_surface = nullptr;
+static WGPUAdapter g_adapter = nullptr;
+static WGPUTextureFormat g_format = WGPUTextureFormat_Undefined;
+
+// ... (init_wgpu implementation same as before)
+void init_wgpu(PlatformState* platform_state) {
+ WGPUInstance instance = wgpuCreateInstance(nullptr);
+ if (!instance) {
+ fprintf(stderr, "Failed to create WGPU instance.\n");
+ exit(1);
+ }
+
+ g_surface = platform_create_wgpu_surface(instance, platform_state);
+ if (!g_surface) {
+ fprintf(stderr, "Failed to create WGPU surface.\n");
+ exit(1);
+ }
+
+ WGPURequestAdapterOptions adapter_opts = {};
+ adapter_opts.compatibleSurface = g_surface;
+ adapter_opts.powerPreference = WGPUPowerPreference_HighPerformance;
+
+#if defined(DEMO_CROSS_COMPILE_WIN32)
+ auto on_adapter = [](WGPURequestAdapterStatus status, WGPUAdapter adapter,
+ const char* message, void* userdata) {
+ if (status == WGPURequestAdapterStatus_Success) {
+ *(WGPUAdapter*)userdata = adapter;
+ }
+ };
+ wgpuInstanceRequestAdapter(instance, &adapter_opts, on_adapter, &g_adapter);
+#else
+ auto on_adapter = [](WGPURequestAdapterStatus status, WGPUAdapter adapter,
+ WGPUStringView message, void* userdata, void* user2) {
+ (void)user2;
+ if (status == WGPURequestAdapterStatus_Success) {
+ *(WGPUAdapter*)userdata = adapter;
+ }
+ };
+ WGPURequestAdapterCallbackInfo adapter_cb = {};
+ adapter_cb.mode = WGPUCallbackMode_WaitAnyOnly;
+ adapter_cb.callback = on_adapter;
+ adapter_cb.userdata1 = &g_adapter;
+ wgpuInstanceRequestAdapter(instance, &adapter_opts, adapter_cb);
+#endif
+
+ while (!g_adapter) {
+ platform_wgpu_wait_any(instance);
+ }
+
+ WGPUDeviceDescriptor device_desc = {};
+
+#if defined(DEMO_CROSS_COMPILE_WIN32)
+ auto on_device = [](WGPURequestDeviceStatus status, WGPUDevice device,
+ const char* message, void* userdata) {
+ if (status == WGPURequestDeviceStatus_Success) {
+ *(WGPUDevice*)userdata = device;
+ }
+ };
+ wgpuAdapterRequestDevice(g_adapter, &device_desc, on_device, &g_device);
+#else
+ auto on_device = [](WGPURequestDeviceStatus status, WGPUDevice device,
+ WGPUStringView message, void* userdata, void* user2) {
+ (void)user2;
+ if (status == WGPURequestDeviceStatus_Success) {
+ *(WGPUDevice*)userdata = device;
+ }
+ };
+ WGPURequestDeviceCallbackInfo device_cb = {};
+ device_cb.mode = WGPUCallbackMode_WaitAnyOnly;
+ device_cb.callback = on_device;
+ device_cb.userdata1 = &g_device;
+ wgpuAdapterRequestDevice(g_adapter, &device_desc, device_cb);
+#endif
+
+ while (!g_device) {
+ platform_wgpu_wait_any(instance);
+ }
+
+ g_queue = wgpuDeviceGetQueue(g_device);
+
+ WGPUSurfaceCapabilities caps = {};
+ wgpuSurfaceGetCapabilities(g_surface, g_adapter, &caps);
+ g_format = caps.formats[0];
+
+ WGPUSurfaceConfiguration config = {};
+ config.device = g_device;
+ config.format = g_format;
+ config.usage = WGPUTextureUsage_RenderAttachment;
+ config.width = platform_state->width;
+ config.height = platform_state->height;
+ config.presentMode = WGPUPresentMode_Fifo;
+ config.alphaMode = WGPUCompositeAlphaMode_Opaque;
+ wgpuSurfaceConfigure(g_surface, &config);
+}
+
+void setup_scene() {
+ g_scene.clear();
+ srand(12345); // Fixed seed
+
+ // Large floor, use BOX type (SDF) at index 0
+ Object3D floor(ObjectType::BOX);
+ floor.position = vec3(0, -2.0f, 0);
+ floor.scale = vec3(25.0f, 0.2f, 25.0f);
+ floor.color = vec4(0.8f, 0.8f, 0.8f, 1.0f);
+ floor.is_static = true;
+ g_scene.add_object(floor);
+
+ // Large center Torus (SDF)
+ Object3D center(ObjectType::TORUS);
+ center.position = vec3(0, 1.0f, 0);
+ center.scale = vec3(2.5f, 2.5f, 2.5f);
+ center.color = vec4(1, 0.2, 0.2, 1);
+ center.is_static = false;
+ center.restitution = 0.8f;
+ g_scene.add_object(center);
+
+ // Moving Sphere (SDF)
+ Object3D sphere(ObjectType::SPHERE);
+ sphere.position = vec3(4.0f, 2.0f, 0);
+ sphere.scale = vec3(1.5f, 1.5f, 1.5f);
+ sphere.color = vec4(0.2, 1, 0.2, 1);
+ sphere.is_static = false;
+ sphere.velocity = vec3(-2.0f, 5.0f, 1.0f);
+ g_scene.add_object(sphere);
+
+ // Random objects
+ for (int i = 0; i < 30; ++i) {
+ ObjectType type = ObjectType::SPHERE;
+ int r = rand() % 3;
+ if (r == 1)
+ type = ObjectType::TORUS;
+ if (r == 2)
+ type = ObjectType::BOX;
+
+ Object3D obj(type);
+ float angle = (rand() % 360) * 0.01745f;
+ float dist = 3.0f + (rand() % 100) * 0.05f;
+ float height = 5.0f + (rand() % 100) * 0.04f; // Start higher
+ obj.position = vec3(std::cos(angle) * dist, height, std::sin(angle) * dist);
+
+ // Random non-uniform scale for debugging
+ float s = 0.6f + (rand() % 100) * 0.008f;
+ obj.scale = vec3(s, s * 1.2f, s * 0.8f);
+
+ obj.color = vec4((rand() % 100) / 100.0f, (rand() % 100) / 100.0f,
+ (rand() % 100) / 100.0f, 1.0f);
+ obj.is_static = false;
+ obj.velocity =
+ vec3((rand() % 100 - 50) * 0.01f, 0, (rand() % 100 - 50) * 0.01f);
+ g_scene.add_object(obj);
+ }
+}
+
+// Wrapper to generate periodic noise
+bool gen_periodic_noise(uint8_t* buffer, int w, int h, const float* params,
+ int num_params) {
+ if (!procedural::gen_noise(buffer, w, h, params, num_params))
+ return false;
+ float p_params[] = {0.1f}; // 10% overlap
+ return procedural::make_periodic(buffer, w, h, p_params, 1);
+}
+
+int main(int argc, char** argv) {
+ printf("Running 3D Physics Test...\n");
+
+#if !defined(STRIP_ALL)
+ for (int i = 1; i < argc; ++i) {
+ if (strcmp(argv[i], "--debug") == 0) {
+ Renderer3D::SetDebugEnabled(true);
+ }
+ if (strcmp(argv[i], "--no-bvh") == 0) {
+ g_renderer.SetBvhEnabled(false);
+ }
+ }
+#else
+ (void)argc;
+ (void)argv;
+#endif
+
+ PlatformState platform_state = platform_init(false, 1280, 720);
+
+ // The test's own WGPU init sequence
+ init_wgpu(&platform_state);
+
+ InitShaderComposer();
+
+ g_renderer.init(g_device, g_queue, g_format);
+ g_renderer.resize(platform_state.width, platform_state.height);
+
+ g_textures.init(g_device, g_queue);
+ ProceduralTextureDef noise_def;
+ noise_def.width = 256;
+ noise_def.height = 256;
+ noise_def.gen_func = gen_periodic_noise;
+ noise_def.params.push_back(1234.0f);
+ noise_def.params.push_back(16.0f);
+ g_textures.create_procedural_texture("noise", noise_def);
+
+ g_renderer.set_noise_texture(g_textures.get_texture_view("noise"));
+
+ ProceduralTextureDef sky_def;
+ sky_def.width = 512;
+ sky_def.height = 256;
+ sky_def.gen_func = procedural::gen_perlin;
+ sky_def.params = {42.0f, 4.0f, 1.0f, 0.5f, 6.0f};
+ g_textures.create_procedural_texture("sky", sky_def);
+
+ g_renderer.set_sky_texture(g_textures.get_texture_view("sky"));
+
+ setup_scene();
+
+ g_camera.position = vec3(0, 5, 10);
+ g_camera.target = vec3(0, 0, 0);
+
+ while (!platform_should_close(&platform_state)) {
+ platform_poll(&platform_state);
+ float time = (float)platform_state.time;
+
+ float cam_radius = 10.0f + std::sin(time * 0.3f) * 4.0f;
+ float cam_height = 5.0f + std::cos(time * 0.4f) * 3.0f;
+ g_camera.set_look_at(vec3(std::sin(time * 0.5f) * cam_radius, cam_height,
+ std::cos(time * 0.5f) * cam_radius),
+ vec3(0, 0, 0), vec3(0, 1, 0));
+ g_camera.aspect_ratio = platform_state.aspect_ratio;
+
+ static double last_time = 0;
+ float dt = (float)(platform_state.time - last_time);
+ if (dt > 0.1f)
+ dt = 0.1f; // Cap dt for stability
+ last_time = platform_state.time;
+
+ g_physics.update(g_scene, dt);
+
+ BVH bvh;
+ BVHBuilder::build(bvh, g_scene.objects);
+ for (const auto& node : bvh.nodes) {
+ g_renderer.add_debug_aabb({node.min_x, node.min_y, node.min_z},
+ {node.max_x, node.max_y, node.max_z},
+ {0.0f, 1.0f, 0.0f});
+ }
+
+#if !defined(STRIP_ALL)
+ Renderer3D::SetDebugEnabled(true);
+#endif
+
+ WGPUSurfaceTexture surface_tex;
+ wgpuSurfaceGetCurrentTexture(g_surface, &surface_tex);
+ if (surface_tex.status ==
+ WGPUSurfaceGetCurrentTextureStatus_SuccessOptimal) {
+ const WGPUTextureViewDescriptor view_desc = {
+ .format = g_format,
+ .dimension = WGPUTextureViewDimension_2D,
+ .mipLevelCount = 1,
+ .arrayLayerCount = 1,
+ };
+
+ const WGPUTextureView view =
+ wgpuTextureCreateView(surface_tex.texture, &view_desc);
+ g_renderer.render(g_scene, g_camera, time, view);
+ wgpuTextureViewRelease(view);
+ wgpuSurfacePresent(g_surface);
+ wgpuTextureRelease(surface_tex.texture);
+ }
+ }
+
+ g_renderer.shutdown();
+ g_textures.shutdown();
+ platform_shutdown(&platform_state);
+ return 0;
+} \ No newline at end of file
diff --git a/src/tests/3d/test_3d_render.cc b/src/tests/3d/test_3d_render.cc
new file mode 100644
index 0000000..eee46ba
--- /dev/null
+++ b/src/tests/3d/test_3d_render.cc
@@ -0,0 +1,326 @@
+// This file is part of the 64k demo project.
+// Standalone "mini-demo" for testing the 3D renderer.
+
+#include "3d/camera.h"
+#include "3d/object.h"
+#include "3d/renderer.h"
+#include "3d/scene.h"
+#include "generated/assets.h"
+#include "gpu/effects/shaders.h"
+#include "gpu/texture_manager.h"
+#include "platform/platform.h"
+#include "procedural/generator.h"
+#include <cmath>
+#include <cstdio>
+#include <cstring>
+#include <vector>
+
+// Global State
+static Renderer3D g_renderer;
+static TextureManager g_textures;
+static Scene g_scene;
+static Camera g_camera;
+static WGPUDevice g_device = nullptr;
+static WGPUQueue g_queue = nullptr;
+static WGPUSurface g_surface = nullptr;
+static WGPUAdapter g_adapter = nullptr;
+static WGPUTextureFormat g_format = WGPUTextureFormat_Undefined;
+
+// ... (init_wgpu implementation same as before)
+void init_wgpu(PlatformState* platform_state) {
+ WGPUInstance instance = wgpuCreateInstance(nullptr);
+ if (!instance) {
+ fprintf(stderr, "Failed to create WGPU instance.\n");
+ exit(1);
+ }
+
+ g_surface = platform_create_wgpu_surface(instance, platform_state);
+ if (!g_surface) {
+ fprintf(stderr, "Failed to create WGPU surface.\n");
+ exit(1);
+ }
+
+ WGPURequestAdapterOptions adapter_opts = {};
+ adapter_opts.compatibleSurface = g_surface;
+ adapter_opts.powerPreference = WGPUPowerPreference_HighPerformance;
+
+#if defined(DEMO_CROSS_COMPILE_WIN32)
+ auto on_adapter = [](WGPURequestAdapterStatus status, WGPUAdapter adapter,
+ const char* message, void* userdata) {
+ if (status == WGPURequestAdapterStatus_Success) {
+ *(WGPUAdapter*)userdata = adapter;
+ }
+ };
+ wgpuInstanceRequestAdapter(instance, &adapter_opts, on_adapter, &g_adapter);
+#else
+ auto on_adapter = [](WGPURequestAdapterStatus status, WGPUAdapter adapter,
+ WGPUStringView message, void* userdata, void* user2) {
+ (void)user2;
+ if (status == WGPURequestAdapterStatus_Success) {
+ *(WGPUAdapter*)userdata = adapter;
+ }
+ };
+ WGPURequestAdapterCallbackInfo adapter_cb = {};
+ adapter_cb.mode = WGPUCallbackMode_WaitAnyOnly;
+ adapter_cb.callback = on_adapter;
+ adapter_cb.userdata1 = &g_adapter;
+ wgpuInstanceRequestAdapter(instance, &adapter_opts, adapter_cb);
+#endif
+
+ while (!g_adapter) {
+ platform_wgpu_wait_any(instance);
+ }
+
+ WGPUDeviceDescriptor device_desc = {};
+
+#if defined(DEMO_CROSS_COMPILE_WIN32)
+ auto on_device = [](WGPURequestDeviceStatus status, WGPUDevice device,
+ const char* message, void* userdata) {
+ if (status == WGPURequestDeviceStatus_Success) {
+ *(WGPUDevice*)userdata = device;
+ }
+ };
+ wgpuAdapterRequestDevice(g_adapter, &device_desc, on_device, &g_device);
+#else
+ auto on_device = [](WGPURequestDeviceStatus status, WGPUDevice device,
+ WGPUStringView message, void* userdata, void* user2) {
+ (void)user2;
+ if (status == WGPURequestDeviceStatus_Success) {
+ *(WGPUDevice*)userdata = device;
+ }
+ };
+ WGPURequestDeviceCallbackInfo device_cb = {};
+ device_cb.mode = WGPUCallbackMode_WaitAnyOnly;
+ device_cb.callback = on_device;
+ device_cb.userdata1 = &g_device;
+ wgpuAdapterRequestDevice(g_adapter, &device_desc, device_cb);
+#endif
+
+ while (!g_device) {
+ platform_wgpu_wait_any(instance);
+ }
+
+ g_queue = wgpuDeviceGetQueue(g_device);
+
+ WGPUSurfaceCapabilities caps = {};
+ wgpuSurfaceGetCapabilities(g_surface, g_adapter, &caps);
+ g_format = caps.formats[0];
+
+ WGPUSurfaceConfiguration config = {};
+ config.device = g_device;
+ config.format = g_format;
+ config.usage = WGPUTextureUsage_RenderAttachment;
+ config.width = platform_state->width;
+ config.height = platform_state->height;
+ config.presentMode = WGPUPresentMode_Fifo;
+ config.alphaMode = WGPUCompositeAlphaMode_Opaque;
+ wgpuSurfaceConfigure(g_surface, &config);
+}
+
+void setup_scene() {
+ g_scene.clear();
+ srand(12345); // Fixed seed
+
+ // Large floor, use BOX type (SDF) at index 0
+ Object3D floor(ObjectType::BOX);
+ floor.position = vec3(0, -2.0f, 0);
+ floor.scale = vec3(25.0f, 0.2f, 25.0f);
+ floor.color = vec4(0.8f, 0.8f, 0.8f, 1.0f);
+ g_scene.add_object(floor);
+
+ // Large center Torus (SDF)
+ Object3D center(ObjectType::TORUS);
+ center.position = vec3(0, 1.0f, 0);
+ center.scale = vec3(2.5f, 2.5f, 2.5f);
+ center.color = vec4(1, 0.2, 0.2, 1);
+ g_scene.add_object(center);
+
+ // Moving Sphere (SDF)
+ Object3D sphere(ObjectType::SPHERE);
+ sphere.position = vec3(4.0f, 2.0f, 0);
+ sphere.scale = vec3(1.5f, 1.5f, 1.5f);
+ sphere.color = vec4(0.2, 1, 0.2, 1);
+ g_scene.add_object(sphere);
+
+ // Mesh Object (Rasterized)
+ Object3D mesh_obj(ObjectType::MESH);
+ mesh_obj.position = vec3(-4.0f, 2.0f, 0);
+ mesh_obj.scale = vec3(2.0f, 2.0f, 2.0f);
+ mesh_obj.color = vec4(0.2, 0.2, 1, 1);
+ mesh_obj.mesh_asset_id = AssetId::ASSET_MESH_CUBE;
+ g_scene.add_object(mesh_obj);
+
+ // Dodecahedron (Rasterized)
+ Object3D dodeca(ObjectType::MESH);
+ dodeca.position = vec3(4.0f, 2.0f, 2.0f);
+ dodeca.scale = vec3(1.0f, 1.0f, 1.0f);
+ dodeca.color = vec4(1.0, 0.5, 0.0, 1); // Orange
+ dodeca.mesh_asset_id = AssetId::ASSET_DODECAHEDRON;
+ g_scene.add_object(dodeca);
+
+ // Random objects
+ for (int i = 0; i < 30; ++i) {
+ ObjectType type = ObjectType::SPHERE;
+ int r = rand() % 3;
+ if (r == 1)
+ type = ObjectType::TORUS;
+ if (r == 2)
+ type = ObjectType::BOX;
+
+ Object3D obj(type);
+ float angle = (rand() % 360) * 0.01745f;
+ float dist = 3.0f + (rand() % 100) * 0.05f;
+ float height = 0.5f + (rand() % 100) * 0.04f;
+ obj.position = vec3(std::cos(angle) * dist, height, std::sin(angle) * dist);
+
+ // Random non-uniform scale for debugging
+ float s = 0.6f + (rand() % 100) * 0.008f;
+ obj.scale = vec3(s, s * 1.2f, s * 0.8f);
+
+ obj.color = vec4((rand() % 100) / 100.0f, (rand() % 100) / 100.0f,
+ (rand() % 100) / 100.0f, 1.0f);
+ g_scene.add_object(obj);
+ }
+}
+
+// Wrapper to generate periodic noise
+bool gen_periodic_noise(uint8_t* buffer, int w, int h, const float* params,
+ int num_params) {
+ if (!procedural::gen_noise(buffer, w, h, params, num_params))
+ return false;
+ float p_params[] = {0.1f}; // 10% overlap
+ return procedural::make_periodic(buffer, w, h, p_params, 1);
+}
+
+int main(int argc, char** argv) {
+ printf("Running 3D Renderer Test...\n");
+
+#if !defined(STRIP_ALL)
+ for (int i = 1; i < argc; ++i) {
+ if (strcmp(argv[i], "--debug") == 0) {
+ Renderer3D::SetDebugEnabled(true);
+ }
+ if (strcmp(argv[i], "--no-bvh") == 0) {
+ g_renderer.SetBvhEnabled(false);
+ }
+ }
+#else
+ (void)argc;
+ (void)argv;
+#endif
+
+ PlatformState platform_state = platform_init(false, 1280, 720);
+
+ // The test's own WGPU init sequence
+ init_wgpu(&platform_state);
+
+ InitShaderComposer();
+
+ g_renderer.init(g_device, g_queue, g_format);
+ g_renderer.resize(platform_state.width, platform_state.height);
+
+ g_textures.init(g_device, g_queue);
+
+ // GPU Noise texture (replaces CPU procedural)
+ GpuProceduralParams noise_params = {};
+ noise_params.width = 256;
+ noise_params.height = 256;
+ float noise_vals[2] = {1234.0f, 16.0f};
+ noise_params.params = noise_vals;
+ noise_params.num_params = 2;
+ g_textures.create_gpu_noise_texture("noise", noise_params);
+ g_renderer.set_noise_texture(g_textures.get_texture_view("noise"));
+
+ // GPU Perlin texture for sky (replaces CPU procedural)
+ GpuProceduralParams sky_params = {};
+ sky_params.width = 512;
+ sky_params.height = 256;
+ float sky_vals[5] = {42.0f, 4.0f, 1.0f, 0.5f, 6.0f};
+ sky_params.params = sky_vals;
+ sky_params.num_params = 5;
+ g_textures.create_gpu_perlin_texture("sky", sky_params);
+ g_renderer.set_sky_texture(g_textures.get_texture_view("sky"));
+
+ // GPU Grid texture (new!)
+ GpuProceduralParams grid_params = {};
+ grid_params.width = 256;
+ grid_params.height = 256;
+ float grid_vals[2] = {32.0f, 2.0f}; // grid_size, thickness
+ grid_params.params = grid_vals;
+ grid_params.num_params = 2;
+ g_textures.create_gpu_grid_texture("grid", grid_params);
+
+ setup_scene();
+
+ g_camera.position = vec3(0, 5, 10);
+ g_camera.target = vec3(0, 0, 0);
+
+ while (!platform_should_close(&platform_state)) {
+ platform_poll(&platform_state);
+ float time = (float)platform_state.time;
+
+ float cam_radius = 10.0f + std::sin(time * 0.3f) * 4.0f;
+ float cam_height = 5.0f + std::cos(time * 0.4f) * 3.0f;
+ g_camera.set_look_at(vec3(std::sin(time * 0.5f) * cam_radius, cam_height,
+ std::cos(time * 0.5f) * cam_radius),
+ vec3(0, 0, 0), vec3(0, 1, 0));
+ g_camera.aspect_ratio = platform_state.aspect_ratio;
+
+ for (size_t i = 1; i < g_scene.objects.size(); ++i) {
+ // Rotation around a random-ish 3D axis
+ vec3 axis =
+ vec3(std::sin((float)i), std::cos((float)i), 0.5f).normalize();
+ g_scene.objects[i].rotation = quat::from_axis(axis, time * 2.0f + i);
+
+ // Non-uniform scaling variance
+ float s = 0.5f + 0.1f * std::sin(time * 0.5f + i);
+ g_scene.objects[i].scale = vec3(s, s * 1.4f, s * 0.8f);
+
+ g_scene.objects[i].position.y = std::sin(time * 3.0f + i) * 1.5f;
+ }
+
+#if !defined(STRIP_ALL)
+ Renderer3D::SetDebugEnabled(true);
+ VisualDebug& dbg = g_renderer.GetVisualDebug();
+ dbg.add_cross(vec3(0, 0, 0), 1.0f, vec3(1, 0, 0));
+ dbg.add_sphere(vec3(std::sin(time) * 2.0f, 3.0f, std::cos(time) * 2.0f),
+ 0.5f, vec3(0, 1, 1));
+ dbg.add_line(vec3(0, 0, 0), vec3(0, 5, 0), vec3(1, 0, 1));
+
+ // Cone (Spotlight visualization)
+ dbg.add_cone(vec3(0, 5, 0), vec3(0, -1, 0), 2.0f, 1.0f, vec3(1, 1, 0));
+
+ // Trajectory path
+ std::vector<vec3> path;
+ for (int i = 0; i <= 32; ++i) {
+ float a = i * 6.28318f / 32.0f;
+ path.push_back(vec3(std::sin(a) * 4.0f, 0.5f, std::cos(a) * 4.0f));
+ }
+ dbg.add_trajectory(path, vec3(0, 0.5f, 1.0f));
+#endif
+
+ WGPUSurfaceTexture surface_tex;
+ wgpuSurfaceGetCurrentTexture(g_surface, &surface_tex);
+ if (surface_tex.status ==
+ WGPUSurfaceGetCurrentTextureStatus_SuccessOptimal) {
+ const WGPUTextureViewDescriptor view_desc = {
+ .format = g_format,
+ .dimension = WGPUTextureViewDimension_2D,
+ .mipLevelCount = 1,
+ .arrayLayerCount = 1,
+ };
+
+ const WGPUTextureView view =
+ wgpuTextureCreateView(surface_tex.texture, &view_desc);
+ g_renderer.render(g_scene, g_camera, time, view);
+ wgpuTextureViewRelease(view);
+ wgpuSurfacePresent(g_surface);
+ wgpuTextureRelease(surface_tex.texture);
+ }
+ }
+
+ g_renderer.shutdown();
+ g_textures.shutdown();
+ platform_shutdown(&platform_state);
+ return 0;
+} \ No newline at end of file
diff --git a/src/tests/3d/test_mesh.cc b/src/tests/3d/test_mesh.cc
new file mode 100644
index 0000000..2129bc8
--- /dev/null
+++ b/src/tests/3d/test_mesh.cc
@@ -0,0 +1,425 @@
+// This file is part of the 64k demo project.
+// Standalone test for loading and rendering a single mesh from a .obj file.
+
+#include "3d/camera.h"
+#include "3d/object.h"
+#include "3d/renderer.h"
+#include "3d/scene.h"
+#include "gpu/effects/shaders.h"
+#include "gpu/texture_manager.h"
+#include "platform/platform.h"
+#include "procedural/generator.h"
+#include "util/asset_manager_utils.h"
+#include <algorithm>
+#include <cmath>
+#include <cstdio>
+#include <cstring>
+#include <fstream>
+#include <map>
+#include <vector>
+#include <webgpu.h>
+
+// Global State
+static Renderer3D g_renderer;
+static TextureManager g_textures;
+static Scene g_scene;
+static Camera g_camera;
+static WGPUDevice g_device = nullptr;
+static WGPUQueue g_queue = nullptr;
+static WGPUSurface g_surface = nullptr;
+static WGPUAdapter g_adapter = nullptr;
+static WGPUTextureFormat g_format = WGPUTextureFormat_Undefined;
+
+// Test-specific storage for mesh buffers
+static Renderer3D::MeshGpuData g_mesh_gpu_data;
+
+// Callbacks for asynchronous WGPU initialization (matches test_3d_render.cc)
+void on_adapter_request_ended(WGPURequestAdapterStatus status,
+ WGPUAdapter adapter, WGPUStringView message,
+ void* userdata, void* user2) {
+ (void)user2;
+ if (status == WGPURequestAdapterStatus_Success) {
+ *(WGPUAdapter*)userdata = adapter;
+ } else {
+ fprintf(stderr,
+ "Failed to request adapter.\n"); // Avoid WGPUStringView::s issues
+ }
+}
+
+void on_device_request_ended(WGPURequestDeviceStatus status, WGPUDevice device,
+ WGPUStringView message, void* userdata,
+ void* user2) {
+ (void)user2;
+ if (status == WGPURequestDeviceStatus_Success) {
+ *(WGPUDevice*)userdata = device;
+ } else {
+ fprintf(stderr,
+ "Failed to request device.\n"); // Avoid WGPUStringView::s issues
+ }
+}
+
+// --- WGPU Boilerplate ---
+void init_wgpu(WGPUInstance instance, PlatformState* platform_state) {
+ if (!instance) {
+ fprintf(stderr, "Failed to create WGPU instance.\n");
+ exit(1);
+ }
+
+ g_surface = platform_create_wgpu_surface(instance, platform_state);
+ if (!g_surface) {
+ fprintf(stderr, "Failed to create WGPU surface.\n");
+ exit(1);
+ }
+
+ // Request Adapter
+ WGPURequestAdapterOptions adapter_opts = {};
+ adapter_opts.compatibleSurface = g_surface;
+ adapter_opts.powerPreference = WGPUPowerPreference_HighPerformance;
+
+ WGPURequestAdapterCallbackInfo adapter_callback_info = {};
+ adapter_callback_info.mode = WGPUCallbackMode_WaitAnyOnly;
+ adapter_callback_info.callback = on_adapter_request_ended;
+ adapter_callback_info.userdata1 = &g_adapter; // Corrected to userdata1
+
+ wgpuInstanceRequestAdapter(instance, &adapter_opts, adapter_callback_info);
+
+ // Busy-wait for adapter
+ while (!g_adapter) {
+ platform_wgpu_wait_any(instance);
+ }
+
+ // Request Device
+ WGPUDeviceDescriptor device_desc = {};
+ WGPURequestDeviceCallbackInfo device_callback_info = {};
+ device_callback_info.mode = WGPUCallbackMode_WaitAnyOnly;
+ device_callback_info.callback = on_device_request_ended;
+ device_callback_info.userdata1 = &g_device; // Corrected to userdata1
+
+ wgpuAdapterRequestDevice(g_adapter, &device_desc, device_callback_info);
+
+ // Busy-wait for device
+ while (!g_device) {
+ platform_wgpu_wait_any(instance);
+ }
+
+ g_queue = wgpuDeviceGetQueue(g_device);
+
+ WGPUSurfaceCapabilities caps = {};
+ wgpuSurfaceGetCapabilities(g_surface, g_adapter, &caps);
+ g_format = caps.formats[0];
+
+ WGPUSurfaceConfiguration config = {};
+ config.device = g_device;
+ config.format = g_format;
+ config.usage = WGPUTextureUsage_RenderAttachment;
+ config.width = platform_state->width;
+ config.height = platform_state->height;
+ config.presentMode = WGPUPresentMode_Fifo;
+ config.alphaMode = WGPUCompositeAlphaMode_Opaque;
+ wgpuSurfaceConfigure(g_surface, &config);
+}
+
+// --- OBJ Loading Logic ---
+#include <cmath> // For std::sqrt
+
+struct Vec3 {
+ float x, y, z;
+ Vec3 operator+(const Vec3& o) const {
+ return {x + o.x, y + o.y, z + o.z};
+ }
+ Vec3& operator+=(const Vec3& o) {
+ x += o.x;
+ y += o.y;
+ z += o.z;
+ return *this;
+ }
+ Vec3 operator-(const Vec3& o) const {
+ return {x - o.x, y - o.y, z - o.z};
+ }
+ Vec3 operator*(float s) const {
+ return {x * s, y * s, z * s};
+ }
+ static Vec3 cross(const Vec3& a, const Vec3& b) {
+ return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z,
+ a.x * b.y - a.y * b.x};
+ }
+ Vec3 normalize() const {
+ float len = std::sqrt(x * x + y * y + z * z);
+ if (len > 1e-6f)
+ return {x / len, y / len, z / len};
+ return {0, 0, 0};
+ }
+};
+
+bool load_obj_and_create_buffers(const char* path, Object3D& out_obj) {
+ std::ifstream obj_file(path);
+ if (!obj_file.is_open()) {
+ fprintf(stderr, "Error: Could not open mesh file: %s\n", path);
+ return false;
+ }
+
+ std::vector<float> v_pos, v_norm, v_uv;
+ struct RawFace {
+ int v[3], vt[3], vn[3];
+ };
+ std::vector<RawFace> raw_faces;
+ std::vector<MeshVertex> final_vertices;
+ std::vector<uint32_t> final_indices;
+ std::map<std::string, uint32_t> vertex_map;
+
+ std::string obj_line;
+ while (std::getline(obj_file, obj_line)) {
+ if (obj_line.compare(0, 2, "v ") == 0) {
+ float x, y, z;
+ sscanf(obj_line.c_str(), "v %f %f %f", &x, &y, &z);
+ v_pos.insert(v_pos.end(), {x, y, z});
+ } else if (obj_line.compare(0, 3, "vn ") == 0) {
+ float x, y, z;
+ sscanf(obj_line.c_str(), "vn %f %f %f", &x, &y, &z);
+ v_norm.insert(v_norm.end(), {x, y, z});
+ } else if (obj_line.compare(0, 3, "vt ") == 0) {
+ float u, v;
+ sscanf(obj_line.c_str(), "vt %f %f", &u, &v);
+ v_uv.insert(v_uv.end(), {u, v});
+ } else if (obj_line.compare(0, 2, "f ") == 0) {
+ char s1[64], s2[64], s3[64];
+ if (sscanf(obj_line.c_str(), "f %s %s %s", s1, s2, s3) == 3) {
+ std::string parts[3] = {s1, s2, s3};
+ RawFace face = {};
+ for (int i = 0; i < 3; ++i) {
+ // Handle v//vn format
+ if (parts[i].find("//") != std::string::npos) {
+ sscanf(parts[i].c_str(), "%d//%d", &face.v[i], &face.vn[i]);
+ face.vt[i] = 0;
+ } else {
+ int res = sscanf(parts[i].c_str(), "%d/%d/%d", &face.v[i],
+ &face.vt[i], &face.vn[i]);
+ if (res == 2)
+ face.vn[i] = 0;
+ else if (res == 1) {
+ face.vt[i] = 0;
+ face.vn[i] = 0;
+ }
+ }
+ }
+ raw_faces.push_back(face);
+ }
+ }
+ }
+
+ if (v_norm.empty() && !v_pos.empty()) {
+ std::vector<Vec3> temp_normals(v_pos.size() / 3, {0, 0, 0});
+ for (auto& face : raw_faces) {
+ int i0 = face.v[0] - 1, i1 = face.v[1] - 1, i2 = face.v[2] - 1;
+ Vec3 p0 = {v_pos[i0 * 3], v_pos[i0 * 3 + 1], v_pos[i0 * 3 + 2]};
+ Vec3 p1 = {v_pos[i1 * 3], v_pos[i1 * 3 + 1], v_pos[i1 * 3 + 2]};
+ Vec3 p2 = {v_pos[i2 * 3], v_pos[i2 * 3 + 1], v_pos[i2 * 3 + 2]};
+ Vec3 n = Vec3::cross(p1 - p0, p2 - p0).normalize();
+ temp_normals[i0] += n;
+ temp_normals[i1] += n;
+ temp_normals[i2] += n;
+ }
+ for (const auto& n : temp_normals) {
+ Vec3 norm = n.normalize();
+ v_norm.insert(v_norm.end(), {norm.x, norm.y, norm.z});
+ }
+ for (auto& face : raw_faces) {
+ face.vn[0] = face.v[0];
+ face.vn[1] = face.v[1];
+ face.vn[2] = face.v[2];
+ }
+ }
+
+ for (const auto& face : raw_faces) {
+ for (int i = 0; i < 3; ++i) {
+ char key_buf[128];
+ snprintf(key_buf, sizeof(key_buf), "%d/%d/%d", face.v[i], face.vt[i],
+ face.vn[i]);
+ std::string key = key_buf;
+ if (vertex_map.find(key) == vertex_map.end()) {
+ vertex_map[key] = (uint32_t)final_vertices.size();
+ MeshVertex v = {};
+ if (face.v[i] > 0) {
+ v.p[0] = v_pos[(face.v[i] - 1) * 3];
+ v.p[1] = v_pos[(face.v[i] - 1) * 3 + 1];
+ v.p[2] = v_pos[(face.v[i] - 1) * 3 + 2];
+ }
+ if (face.vn[i] > 0) {
+ v.n[0] = v_norm[(face.vn[i] - 1) * 3];
+ v.n[1] = v_norm[(face.vn[i] - 1) * 3 + 1];
+ v.n[2] = v_norm[(face.vn[i] - 1) * 3 + 2];
+ }
+ if (face.vt[i] > 0) {
+ v.u[0] = v_uv[(face.vt[i] - 1) * 2];
+ v.u[1] = v_uv[(face.vt[i] - 1) * 2 + 1];
+ }
+ final_vertices.push_back(v);
+ }
+ final_indices.push_back(vertex_map[key]);
+ }
+ }
+
+ if (final_vertices.empty())
+ return false;
+
+ // Calculate AABB and center the mesh
+ float min_x = 1e10f, min_y = 1e10f, min_z = 1e10f;
+ float max_x = -1e10f, max_y = -1e10f, max_z = -1e10f;
+ for (const auto& v : final_vertices) {
+ min_x = std::min(min_x, v.p[0]);
+ min_y = std::min(min_y, v.p[1]);
+ min_z = std::min(min_z, v.p[2]);
+ max_x = std::max(max_x, v.p[0]);
+ max_y = std::max(max_y, v.p[1]);
+ max_z = std::max(max_z, v.p[2]);
+ }
+ float cx = (min_x + max_x) * 0.5f;
+ float cy = (min_y + max_y) * 0.5f;
+ float cz = (min_z + max_z) * 0.5f;
+ for (auto& v : final_vertices) {
+ v.p[0] -= cx;
+ v.p[1] -= cy;
+ v.p[2] -= cz;
+ }
+ out_obj.local_extent = vec3((max_x - min_x) * 0.5f, (max_y - min_y) * 0.5f,
+ (max_z - min_z) * 0.5f);
+
+ g_mesh_gpu_data.num_indices = final_indices.size();
+ g_mesh_gpu_data.vertex_buffer =
+ gpu_create_buffer(g_device, final_vertices.size() * sizeof(MeshVertex),
+ WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst,
+ final_vertices.data())
+ .buffer;
+ g_mesh_gpu_data.index_buffer =
+ gpu_create_buffer(g_device, final_indices.size() * sizeof(uint32_t),
+ WGPUBufferUsage_Index | WGPUBufferUsage_CopyDst,
+ final_indices.data())
+ .buffer;
+
+ struct MeshData {
+ std::vector<MeshVertex> vertices;
+ std::vector<uint32_t> indices;
+ };
+ MeshData* mesh_data = new MeshData();
+ mesh_data->vertices = final_vertices;
+ mesh_data->indices = final_indices;
+
+ out_obj.type = ObjectType::MESH;
+ out_obj.user_data = mesh_data;
+
+ // This test doesn't use the asset system, so we override the renderer's
+ // internal cache lookup by manually setting the buffers on the renderer
+ // object. This is a HACK for this specific tool.
+ g_renderer.override_mesh_buffers(&g_mesh_gpu_data);
+
+ return true;
+}
+
+int main(int argc, char** argv) {
+ if (argc < 2) {
+ printf("Usage: %s <path/to/mesh.obj> [--debug]\n", argv[0]);
+ return 1;
+ }
+ const char* obj_path = argv[1];
+ bool debug_mode = (argc > 2 && strcmp(argv[2], "--debug") == 0);
+
+ printf("Loading mesh: %s\n", obj_path);
+
+ PlatformState platform_state = platform_init(false, 1280, 720);
+
+ WGPUInstance instance = wgpuCreateInstance(nullptr);
+ init_wgpu(instance, &platform_state);
+ InitShaderComposer();
+
+ g_renderer.init(g_device, g_queue, g_format);
+ g_renderer.resize(platform_state.width, platform_state.height);
+#if !defined(STRIP_ALL)
+ if (debug_mode) {
+ Renderer3D::SetDebugEnabled(true);
+ }
+#endif /* !defined(STRIP_ALL) */
+
+ g_textures.init(g_device, g_queue);
+ ProceduralTextureDef noise_def;
+ noise_def.width = 256;
+ noise_def.height = 256;
+ noise_def.gen_func = procedural::gen_noise;
+ noise_def.params = {1234.0f, 16.0f};
+ g_textures.create_procedural_texture("noise", noise_def);
+ g_renderer.set_noise_texture(g_textures.get_texture_view("noise"));
+
+ // --- Create Scene ---
+ Object3D floor(ObjectType::BOX);
+ floor.position = vec3(0, -2.0f, 0);
+ floor.scale = vec3(25.0f, 0.2f, 25.0f);
+ floor.color = vec4(0.5f, 0.5f, 0.5f, 1.0f);
+ g_scene.add_object(floor);
+
+ Object3D mesh_obj;
+ if (!load_obj_and_create_buffers(obj_path, mesh_obj)) {
+ printf("Failed to load or process OBJ file.\n");
+ return 1;
+ }
+ mesh_obj.color = vec4(1.0f, 0.7f, 0.2f, 1.0f);
+ mesh_obj.position = {0, 1.5, 0}; // Elevate a bit more
+ g_scene.add_object(mesh_obj);
+
+ g_camera.position = vec3(0, 3, 5);
+ g_camera.target = vec3(0, 1.5, 0);
+
+ while (!platform_should_close(&platform_state)) {
+ platform_poll(&platform_state);
+ float time = (float)platform_state.time;
+
+ g_camera.aspect_ratio = platform_state.aspect_ratio;
+
+ g_scene.objects[1].rotation = quat::from_axis({0.5f, 1.0f, 0.0f}, time);
+
+#if !defined(STRIP_ALL)
+ if (debug_mode) {
+ struct MeshData {
+ std::vector<MeshVertex> vertices;
+ std::vector<uint32_t> indices;
+ };
+ auto* data = (MeshData*)g_scene.objects[1].user_data;
+ VisualDebug& dbg = g_renderer.GetVisualDebug();
+ dbg.add_mesh_normals(g_scene.objects[1].get_model_matrix(),
+ (uint32_t)data->vertices.size(),
+ data->vertices.data());
+ // Wireframe is now handled automatically by renderer
+ }
+#endif /* !defined(STRIP_ALL) */
+
+ WGPUSurfaceTexture surface_tex;
+ wgpuSurfaceGetCurrentTexture(g_surface, &surface_tex);
+ if (surface_tex.status ==
+ WGPUSurfaceGetCurrentTextureStatus_SuccessOptimal) { // WGPUSurfaceGetCurrentTextureStatus_Success
+ // is 0
+ WGPUTextureView view =
+ wgpuTextureCreateView(surface_tex.texture, nullptr);
+ g_renderer.render(g_scene, g_camera, time, view);
+ wgpuTextureViewRelease(view);
+ wgpuSurfacePresent(g_surface);
+ }
+ wgpuTextureRelease(
+ surface_tex
+ .texture); // Release here, after present, outside the if block
+ }
+
+#if !defined(STRIP_ALL)
+ Renderer3D::SetDebugEnabled(false); // Reset debug mode
+#endif
+
+ struct MeshData {
+ std::vector<MeshVertex> vertices;
+ std::vector<uint32_t> indices;
+ };
+ delete (MeshData*)g_scene.objects[1].user_data;
+ wgpuBufferRelease(g_mesh_gpu_data.vertex_buffer);
+ wgpuBufferRelease(g_mesh_gpu_data.index_buffer);
+
+ g_renderer.shutdown();
+ g_textures.shutdown();
+ platform_shutdown(&platform_state);
+ return 0;
+} \ No newline at end of file
diff --git a/src/tests/3d/test_physics.cc b/src/tests/3d/test_physics.cc
new file mode 100644
index 0000000..df21e70
--- /dev/null
+++ b/src/tests/3d/test_physics.cc
@@ -0,0 +1,150 @@
+// This file is part of the 64k demo project.
+// It tests the CPU-side SDF library and BVH for physics and collision.
+
+#include "3d/bvh.h"
+#include "3d/physics.h"
+#include "3d/sdf_cpu.h"
+#include <cassert>
+#include <cmath>
+#include <iostream>
+
+bool near(float a, float b, float e = 0.001f) {
+ return std::abs(a - b) < e;
+}
+
+void test_sdf_sphere() {
+ std::cout << "Testing sdSphere..." << std::endl;
+ float r = 1.0f;
+ assert(near(sdf::sdSphere({0, 0, 0}, r), -1.0f));
+ assert(near(sdf::sdSphere({1, 0, 0}, r), 0.0f));
+ assert(near(sdf::sdSphere({2, 0, 0}, r), 1.0f));
+}
+
+void test_sdf_box() {
+ std::cout << "Testing sdBox..." << std::endl;
+ vec3 b(1, 1, 1);
+ assert(near(sdf::sdBox({0, 0, 0}, b), -1.0f));
+ assert(near(sdf::sdBox({1, 1, 1}, b), 0.0f));
+ assert(near(sdf::sdBox({2, 0, 0}, b), 1.0f));
+}
+
+void test_sdf_torus() {
+ std::cout << "Testing sdTorus..." << std::endl;
+ vec2 t(1.0f, 0.2f);
+ // Point on the ring: length(p.xz) = 1.0, p.y = 0
+ assert(near(sdf::sdTorus({1, 0, 0}, t), -0.2f));
+ assert(near(sdf::sdTorus({1.2f, 0, 0}, t), 0.0f));
+}
+
+void test_sdf_plane() {
+ std::cout << "Testing sdPlane..." << std::endl;
+ vec3 n(0, 1, 0);
+ float h = 1.0f; // Plane is at y = -1 (dot(p,n) + 1 = 0 => y = -1)
+ assert(near(sdf::sdPlane({0, 0, 0}, n, h), 1.0f));
+ assert(near(sdf::sdPlane({0, -1, 0}, n, h), 0.0f));
+}
+
+void test_calc_normal() {
+ std::cout << "Testing calc_normal..." << std::endl;
+
+ // Sphere normal at (1,0,0) should be (1,0,0)
+ auto sphere_sdf = [](vec3 p) { return sdf::sdSphere(p, 1.0f); };
+ vec3 n = sdf::calc_normal({1, 0, 0}, sphere_sdf);
+ assert(near(n.x, 1.0f) && near(n.y, 0.0f) && near(n.z, 0.0f));
+
+ // Box normal at side
+ auto box_sdf = [](vec3 p) { return sdf::sdBox(p, {1, 1, 1}); };
+ n = sdf::calc_normal({1, 0, 0}, box_sdf);
+ assert(near(n.x, 1.0f) && near(n.y, 0.0f) && near(n.z, 0.0f));
+
+ // Plane normal should be n
+ vec3 plane_n(0, 1, 0);
+ auto plane_sdf = [plane_n](vec3 p) { return sdf::sdPlane(p, plane_n, 1.0f); };
+ n = sdf::calc_normal({0, 0, 0}, plane_sdf);
+ assert(near(n.x, plane_n.x) && near(n.y, plane_n.y) && near(n.z, plane_n.z));
+}
+
+void test_bvh() {
+ std::cout << "Testing BVH..." << std::endl;
+ std::vector<Object3D> objects;
+
+ // Object 0: Left side
+ Object3D obj0(ObjectType::BOX);
+ obj0.position = {-10, 0, 0};
+ objects.push_back(obj0);
+
+ // Object 1: Right side
+ Object3D obj1(ObjectType::BOX);
+ obj1.position = {10, 0, 0};
+ objects.push_back(obj1);
+
+ BVH bvh;
+ BVHBuilder::build(bvh, objects);
+
+ assert(bvh.nodes.size() == 3); // 1 root + 2 leaves
+
+ // Query left side
+ std::vector<int> results;
+ bvh.query({{-12, -2, -2}, {-8, 2, 2}}, results);
+ assert(results.size() == 1);
+ assert(results[0] == 0);
+
+ // Query right side
+ results.clear();
+ bvh.query({{8, -2, -2}, {12, 2, 2}}, results);
+ assert(results.size() == 1);
+ assert(results[0] == 1);
+
+ // Query center (should miss both)
+ results.clear();
+ bvh.query({{-2, -2, -2}, {2, 2, 2}}, results);
+ assert(results.size() == 0);
+
+ // Query both
+ results.clear();
+ bvh.query({{-12, -2, -2}, {12, 2, 2}}, results);
+ assert(results.size() == 2);
+}
+
+void test_physics_falling() {
+ std::cout << "Testing Physics falling..." << std::endl;
+ Scene scene;
+
+ // Plane at y = -1
+ Object3D plane(ObjectType::PLANE);
+ plane.position = {0, -1, 0};
+ plane.is_static = true;
+ scene.add_object(plane);
+
+ // Sphere at y = 5
+ Object3D sphere(ObjectType::SPHERE);
+ sphere.position = {0, 5, 0};
+ sphere.velocity = {0, 0, 0};
+ sphere.restitution = 0.0f; // No bounce for simple test
+ scene.add_object(sphere);
+
+ PhysicsSystem physics;
+ float dt = 0.016f;
+ for (int i = 0; i < 100; ++i) {
+ physics.update(scene, dt);
+ }
+
+ // Sphere should be above or at plane (y >= 0 because sphere radius is 1,
+ // plane is at -1)
+ assert(scene.objects[1].position.y >= -0.01f);
+ // Also should have slowed down
+ assert(scene.objects[1].velocity.y > -1.0f);
+}
+
+int main() {
+ test_sdf_sphere();
+ test_sdf_box();
+ test_sdf_torus();
+ test_sdf_plane();
+ test_calc_normal();
+ test_bvh();
+ test_physics_falling();
+
+ std::cout << "--- ALL PHYSICS TESTS PASSED ---" << std::endl;
+ return 0;
+}
diff --git a/src/tests/3d/test_scene_loader.cc b/src/tests/3d/test_scene_loader.cc
new file mode 100644
index 0000000..21bcbaa
--- /dev/null
+++ b/src/tests/3d/test_scene_loader.cc
@@ -0,0 +1,134 @@
+#include "3d/scene_loader.h"
+#include "generated/assets.h"
+#include "util/asset_manager.h"
+#include "util/mini_math.h"
+#include <cassert>
+#include <cstdio>
+#include <cstring>
+#include <vector>
+
+int main() {
+ Scene scene;
+ std::vector<uint8_t> buffer;
+
+ // Header
+ const char* magic = "SCN1";
+ for (int i = 0; i < 4; ++i)
+ buffer.push_back(magic[i]);
+
+ uint32_t num_obj = 2; // Increased to 2
+ uint32_t num_cam = 0;
+ uint32_t num_light = 0;
+
+ auto push_u32 = [&](uint32_t v) {
+ uint8_t* p = (uint8_t*)&v;
+ for (int i = 0; i < 4; ++i)
+ buffer.push_back(p[i]);
+ };
+ auto push_f = [&](float v) {
+ uint8_t* p = (uint8_t*)&v;
+ for (int i = 0; i < 4; ++i)
+ buffer.push_back(p[i]);
+ };
+
+ push_u32(num_obj);
+ push_u32(num_cam);
+ push_u32(num_light);
+
+ // --- Object 1: Basic Cube ---
+ char name1[64] = {0};
+ std::strcpy(name1, "TestObject");
+ for (int i = 0; i < 64; ++i)
+ buffer.push_back(name1[i]);
+
+ push_u32(0); // CUBE
+
+ // Pos
+ push_f(1.0f);
+ push_f(2.0f);
+ push_f(3.0f);
+ // Rot (0,0,0,1)
+ push_f(0.0f);
+ push_f(0.0f);
+ push_f(0.0f);
+ push_f(1.0f);
+ // Scale
+ push_f(1.0f);
+ push_f(1.0f);
+ push_f(1.0f);
+ // Color
+ push_f(1.0f);
+ push_f(0.0f);
+ push_f(0.0f);
+ push_f(1.0f);
+
+ // Mesh Name length 0
+ push_u32(0);
+
+ // Physics
+ push_f(10.0f); // mass
+ push_f(0.8f); // restitution
+ push_u32(1); // static
+
+ // --- Object 2: Mesh with Asset Ref ---
+ char name2[64] = {0};
+ std::strcpy(name2, "MeshObject");
+ for (int i = 0; i < 64; ++i)
+ buffer.push_back(name2[i]);
+
+ push_u32(6); // MESH
+
+ // Pos
+ push_f(0.0f);
+ push_f(0.0f);
+ push_f(0.0f);
+ // Rot
+ push_f(0.0f);
+ push_f(0.0f);
+ push_f(0.0f);
+ push_f(1.0f);
+ // Scale
+ push_f(1.0f);
+ push_f(1.0f);
+ push_f(1.0f);
+ // Color
+ push_f(0.0f);
+ push_f(1.0f);
+ push_f(0.0f);
+ push_f(1.0f);
+
+ // Mesh Name "MESH_CUBE"
+ const char* mesh_name = "MESH_CUBE";
+ uint32_t mesh_name_len = std::strlen(mesh_name);
+ push_u32(mesh_name_len);
+ for (size_t i = 0; i < mesh_name_len; ++i)
+ buffer.push_back(mesh_name[i]);
+
+ // Physics
+ push_f(1.0f);
+ push_f(0.5f);
+ push_u32(0); // dynamic
+
+ // --- Load ---
+ if (SceneLoader::LoadScene(scene, buffer.data(), buffer.size())) {
+ printf("Scene loaded successfully.\n");
+ assert(scene.objects.size() == 2);
+
+ // Check Obj 1
+ assert(scene.objects[0].type == ObjectType::CUBE);
+ assert(scene.objects[0].position.x == 1.0f);
+ assert(scene.objects[0].is_static == true);
+
+ // Check Obj 2
+ assert(scene.objects[1].type == ObjectType::MESH);
+ assert(scene.objects[1].mesh_asset_id == AssetId::ASSET_MESH_CUBE);
+ printf("Mesh Asset ID resolved to: %d (Expected %d)\n",
+ (int)scene.objects[1].mesh_asset_id, (int)AssetId::ASSET_MESH_CUBE);
+
+ } else {
+ printf("Scene load failed.\n");
+ return 1;
+ }
+
+ return 0;
+}