ADR 0009 (Proposée) et spécification du protocole Core - rendu 0.1. Le Core projette le monde simulé (micro-mode 2D, SIMULÉ) en deltas SEQUA 3D (sequa::core_render) et les pousse après chaque tick qui change la scène (RenderHub, observateur de LiveSession) ; il lance et supervise sequa-render (RenderSupervisor : lié au Core, relance après plantage). sequa-render charge une bibliothèque d'adaptateur par un point d'entrée C versionné (3d/adapter_library.hpp), applique les deltas et rend les images ; codec JSON provisoire partagé dans 3d/wire. Adaptateur Godot en DLL et nouvel adaptateur de débogage (engines/debug/adapter : vue de côté en images PPM ou vidéo via ffmpeg externe). Protocole Studio - Core 0.3 : render/start, render/stop, render/status (Core et Studio host). Mesuré avec Godot : transport tick -> delta reçu p95 ≈ 0,3 ms (critère 4 ms).
288 lines
10 KiB
C++
288 lines
10 KiB
C++
// SPDX-License-Identifier: MPL-2.0
|
|
#include "sequa/core/render/scene_projection.hpp"
|
|
|
|
#include "sequa/core/sim/simulation.hpp"
|
|
|
|
#include <doctest/doctest.h>
|
|
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <variant>
|
|
#include <vector>
|
|
|
|
using namespace sequa::core::sim;
|
|
using sequa::core::render::SceneProjector;
|
|
using namespace sequa::three_d;
|
|
|
|
namespace {
|
|
|
|
constexpr ObjectId ground_id = 1;
|
|
constexpr ObjectId player_id = 2;
|
|
constexpr ObjectId coin_id_base = 1000;
|
|
|
|
// Monde de test deterministe : aucun corpus aleatoire a traiter, tout est pose a la main.
|
|
WorldState world_with_three_coins()
|
|
{
|
|
WorldState world;
|
|
world.next_coin_id = 3;
|
|
world.coins.push_back(Coin{0, Fixed::from_int(2), Fixed::from_ratio(1, 2)});
|
|
world.coins.push_back(Coin{1, Fixed::from_int(-4), Fixed::from_int(1)});
|
|
world.coins.push_back(Coin{2, Fixed::from_int(10), Fixed::from_int(2)});
|
|
return world;
|
|
}
|
|
|
|
// Session scriptee : courses, pauses, demi-tours et sauts periodiques suffisent a faire
|
|
// bouger joueur, pieces et camera. Copie locale des sessions des tests sim (meme idee).
|
|
InputFrame scripted_input(Tick tick)
|
|
{
|
|
InputFrame input = neutral_input(tick);
|
|
const Tick phase = tick % 180;
|
|
if (phase < 90) {
|
|
input.move_x = 32767;
|
|
} else if (phase < 120) {
|
|
input.move_x = 0;
|
|
} else {
|
|
input.move_x = -12000;
|
|
}
|
|
if (tick % 60 < 4) {
|
|
input.buttons = button::jump;
|
|
}
|
|
return input;
|
|
}
|
|
|
|
template <typename Command>
|
|
[[nodiscard]] std::size_t count_commands(const SceneDelta& delta)
|
|
{
|
|
std::size_t count = 0;
|
|
for (const SceneCommand& command : delta.commands) {
|
|
if (std::holds_alternative<Command>(command)) {
|
|
++count;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
template <typename Command>
|
|
[[nodiscard]] const Command* find_command(const SceneDelta& delta, ObjectId id)
|
|
{
|
|
for (const SceneCommand& command : delta.commands) {
|
|
if (const Command* c = std::get_if<Command>(&command); c != nullptr && c->id == id) {
|
|
return c;
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
void check_no_set_visual(const SceneDelta& delta)
|
|
{
|
|
CHECK(count_commands<SetVisual>(delta) == 0);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("SceneProjector : le premier appel renvoie la scene complete")
|
|
{
|
|
SceneProjector projector(ArenaParams{});
|
|
|
|
const SceneDelta delta = projector.update(world_with_three_coins(), 5);
|
|
check_no_set_visual(delta);
|
|
|
|
CHECK(delta.world_version == 5);
|
|
REQUIRE(delta.commands.size() == 6); // sol, joueur, 3 pieces, camera
|
|
|
|
// Les creations arrivent d'abord, par identifiant croissant.
|
|
std::vector<ObjectId> created;
|
|
for (const SceneCommand& command : delta.commands) {
|
|
if (const CreateObject* c = std::get_if<CreateObject>(&command)) {
|
|
created.push_back(c->id);
|
|
}
|
|
}
|
|
CHECK(created == std::vector<ObjectId>{ground_id, player_id, 1000, 1001, 1002});
|
|
CHECK(std::holds_alternative<SetCamera>(delta.commands.back()));
|
|
|
|
const CreateObject* ground = find_command<CreateObject>(delta, ground_id);
|
|
REQUIRE(ground != nullptr);
|
|
CHECK(ground->visual.primitive == Primitive::plane);
|
|
CHECK(ground->visual.size == Vec3{40, 1, 4}); // 2 * half_width par defaut
|
|
CHECK(ground->transform.position == Vec3{0, 0, 0});
|
|
CHECK(ground->visual.color.r < 0.5f); // vert sombre : dominante verte
|
|
CHECK(ground->visual.color.g > ground->visual.color.r);
|
|
CHECK(ground->visual.color.g > ground->visual.color.b);
|
|
|
|
const CreateObject* player = find_command<CreateObject>(delta, player_id);
|
|
REQUIRE(player != nullptr);
|
|
CHECK(player->visual.primitive == Primitive::capsule);
|
|
CHECK(player->visual.size == Vec3{0.8f, 1.8f, 0.8f});
|
|
// Body.y est l'altitude des pieds : le centre de la capsule est une demi-hauteur plus
|
|
// haut, donc a y = 0.9 au sol.
|
|
CHECK(player->transform.position == Vec3{0, 0.9f, 0});
|
|
|
|
const CreateObject* coin = find_command<CreateObject>(delta, coin_id_base);
|
|
REQUIRE(coin != nullptr);
|
|
CHECK(coin->visual.primitive == Primitive::sphere);
|
|
CHECK(coin->visual.size == Vec3{0.5f, 0.5f, 0.5f}); // premiere piece posee a (2, 0.5)
|
|
CHECK(coin->transform.position == Vec3{2, 0.5f, 0});
|
|
|
|
// SetCamera n'a pas d'identifiant : la camera ferme la scene complete.
|
|
const SetCamera* camera_command = std::get_if<SetCamera>(&delta.commands.back());
|
|
REQUIRE(camera_command != nullptr);
|
|
CHECK(camera_command->camera.transform.position == Vec3{0, 3, 12});
|
|
CHECK(camera_command->camera.transform.rotation == Quat{0, 0, 0, 1});
|
|
CHECK(camera_command->camera.vertical_fov_deg == 50.0f);
|
|
CHECK(camera_command->camera.near_plane == 0.1f);
|
|
CHECK(camera_command->camera.far_plane == 200.0f);
|
|
}
|
|
|
|
TEST_CASE("SceneProjector : rien n'a bouge, delta vide")
|
|
{
|
|
SceneProjector projector(ArenaParams{});
|
|
const WorldState world = world_with_three_coins();
|
|
|
|
REQUIRE(projector.update(world, 1).commands.size() == 6);
|
|
|
|
const SceneDelta delta = projector.update(world, 2);
|
|
CHECK(delta.world_version == 2);
|
|
CHECK(delta.commands.empty());
|
|
}
|
|
|
|
TEST_CASE("SceneProjector : seul le joueur bouge, exactement ce qui a change")
|
|
{
|
|
SceneProjector projector(ArenaParams{});
|
|
WorldState world = world_with_three_coins();
|
|
REQUIRE(projector.update(world, 1).commands.size() == 6);
|
|
|
|
SUBCASE("deplacement horizontal : transform et camera")
|
|
{
|
|
world.player.x = Fixed::from_int(1);
|
|
const SceneDelta delta = projector.update(world, 2);
|
|
check_no_set_visual(delta);
|
|
|
|
CHECK(count_commands<SetTransform>(delta) == 1);
|
|
const SetTransform* transform = find_command<SetTransform>(delta, player_id);
|
|
REQUIRE(transform != nullptr);
|
|
CHECK(transform->transform.position == Vec3{1, 0.9f, 0});
|
|
|
|
CHECK(count_commands<SetCamera>(delta) == 1);
|
|
CHECK(count_commands<CreateObject>(delta) == 0);
|
|
CHECK(count_commands<DestroyObject>(delta) == 0);
|
|
}
|
|
SUBCASE("montee verticale seule : transform sans camera")
|
|
{
|
|
world.player.x = Fixed::from_int(1);
|
|
REQUIRE(projector.update(world, 2).commands.size() == 2);
|
|
world.player.y = Fixed::from_ratio(1, 2);
|
|
|
|
const SceneDelta delta = projector.update(world, 3);
|
|
check_no_set_visual(delta);
|
|
|
|
CHECK(count_commands<SetTransform>(delta) == 1);
|
|
const SetTransform* transform = find_command<SetTransform>(delta, player_id);
|
|
REQUIRE(transform != nullptr);
|
|
CHECK(transform->transform.position == Vec3{1, 1.4f, 0}); // pieds a y = 0.5
|
|
CHECK(count_commands<SetCamera>(delta) == 0);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SceneProjector : une piece ramassee devient un DestroyObject")
|
|
{
|
|
SceneProjector projector(ArenaParams{});
|
|
WorldState world = world_with_three_coins();
|
|
REQUIRE(projector.update(world, 1).commands.size() == 6);
|
|
|
|
// La piece d'identifiant 1 a ete ramassee : elle quitte le monde.
|
|
world.coins.erase(world.coins.begin() + 1);
|
|
|
|
const SceneDelta delta = projector.update(world, 2);
|
|
check_no_set_visual(delta);
|
|
|
|
REQUIRE(delta.commands.size() == 1);
|
|
const DestroyObject destroyed = std::get<DestroyObject>(delta.commands.front());
|
|
CHECK(destroyed.id == 1001);
|
|
}
|
|
|
|
TEST_CASE("SceneProjector : une piece apparue devient un CreateObject")
|
|
{
|
|
SceneProjector projector(ArenaParams{});
|
|
WorldState world = world_with_three_coins();
|
|
REQUIRE(projector.update(world, 1).commands.size() == 6);
|
|
|
|
// La piece ramassee reapparait ailleurs sous un nouvel identifiant.
|
|
world.coins.push_back(Coin{5, Fixed::from_int(6), Fixed::from_int(1)});
|
|
|
|
const SceneDelta delta = projector.update(world, 2);
|
|
check_no_set_visual(delta);
|
|
|
|
REQUIRE(delta.commands.size() == 1);
|
|
const CreateObject created = std::get<CreateObject>(delta.commands.front());
|
|
CHECK(created.id == 1005);
|
|
CHECK(created.visual.primitive == Primitive::sphere);
|
|
CHECK(created.transform.position == Vec3{6, 1, 0});
|
|
}
|
|
|
|
TEST_CASE("SceneProjector : reset() renvoie a la scene complete")
|
|
{
|
|
SceneProjector projector(ArenaParams{});
|
|
WorldState world = world_with_three_coins();
|
|
|
|
REQUIRE(projector.update(world, 1).commands.size() == 6);
|
|
world.player.x = Fixed::from_int(1);
|
|
REQUIRE(projector.update(world, 2).commands.size() == 2);
|
|
|
|
projector.reset();
|
|
const SceneDelta delta = projector.update(world, 3);
|
|
check_no_set_visual(delta);
|
|
|
|
// Toute la scene est repoussee, transform du joueur compris.
|
|
CHECK(delta.commands.size() == 6);
|
|
CHECK(count_commands<SetTransform>(delta) == 0);
|
|
CHECK(count_commands<SetCamera>(delta) == 1);
|
|
}
|
|
|
|
TEST_CASE("SceneProjector : une vraie session passee pas a pas est acceptee par SceneState")
|
|
{
|
|
SimConfig config;
|
|
config.seed = 1234;
|
|
Simulation simulation(config);
|
|
SceneProjector projector(simulation.config().arena);
|
|
|
|
SceneState scene;
|
|
std::size_t non_empty_deltas = 0;
|
|
for (Tick tick = 0; tick < 300; ++tick) {
|
|
const SceneDelta delta = projector.update(simulation.world(), tick + 1);
|
|
REQUIRE_FALSE(scene.apply(delta).has_value());
|
|
if (!delta.commands.empty()) {
|
|
++non_empty_deltas;
|
|
}
|
|
for (const SceneCommand& command : delta.commands) {
|
|
CHECK_FALSE(std::holds_alternative<SetVisual>(command));
|
|
}
|
|
simulation.step(scripted_input(tick));
|
|
}
|
|
|
|
CHECK(scene.world_version() == 300);
|
|
CHECK(scene.objects().size() == 2 + simulation.config().arena.coin_count); // sol, joueur, pieces
|
|
CHECK(scene.camera().has_value());
|
|
CHECK(non_empty_deltas > 0); // la session a bien fait bouger le joueur
|
|
}
|
|
|
|
TEST_CASE("SceneProjector : meme suite d'entrees, memes deltas")
|
|
{
|
|
SimConfig config;
|
|
config.seed = 1234;
|
|
Simulation a(config);
|
|
Simulation b(config);
|
|
SceneProjector projector_a(a.config().arena);
|
|
SceneProjector projector_b(b.config().arena);
|
|
|
|
for (Tick tick = 0; tick < 300; ++tick) {
|
|
const SceneDelta delta_a = projector_a.update(a.world(), tick + 1);
|
|
const SceneDelta delta_b = projector_b.update(b.world(), tick + 1);
|
|
CHECK(delta_a.world_version == delta_b.world_version);
|
|
CHECK(delta_a.commands == delta_b.commands);
|
|
|
|
const InputFrame input = scripted_input(tick);
|
|
a.step(input);
|
|
b.step(input);
|
|
}
|
|
}
|