Beef up sessions and scenarios
A few related things are required that are closely related:
A) Allow changing scenarios while the window is open
Requires a bit of refactoring to scenarios.
Sessions for keeping the window open (setup_window_app and setup_magnum) are already managed separately, found in main.cpp.
https://github.com/TheOpenSpaceProgram/osp-magnum/blob/c42ed3195318c3925373b5107636aaac69d17305/src/testapp/main.cpp#L270-L271
This means that scene-related sessions (setup_scene, setup_common_scene, setup_scene_renderer, setup_magnum_scene, setup_camera_ctrl, etc...) can be closed and new ones can be loaded in while the window stays open.
When a session is requested to change...
- Detect request to change session, likely at the end of CommonMagnumApp::draw.
- Call TestApp::close_sessions (runs all tasks with
.cleanup(Run_)) so data can be destructed gracefully. - Clear scene and scene renderer sessions.
- Call new scene setup and renderer setup functions.
B) Have some API to open new sessions
Imagine starting with just a main menu session that only uses UI. The user can then select and load up a flight scenario, launching new sessions (this is why sessions are called sessions).
Challenges:
- Task graph execution has to stop and get cleared for new tasks to be added. Code that runs within tasks (literally everything in-game) can't modify the task graph and add Sessions and tasks directly; this would be ugly anyways. Task code has to send a request up to the top of the application to specify modifications of which sessions to add or remove.
- Who handles drawing to the screen? Should responsibility of the main drawing function be passed between session tasks (this is the current case with setup_magnum_scene), or should there be one 'compositor' task/function at the top that commands how everything below it should be drawn through some API? Imagine a case where the user is sitting in a mission control room, and has screens with live camera views open of multiple missions distant from each other, all being simulated in separate scenes. This favors the compositor solution, where the currently visible scene can call render into the required flight scenes.
In a more finalized product, a list of flights scenes, their sessions, and additional data about them (eg: associations with the universe) can be stored somewhere at the top-level of the application. Renderers can then be optionally assigned to them for the application's compositor function to call into.
C) Automatic runtime Session dependencies
Right now, dependencies between sessions are intended to be easy to write and copy-paste around manually, relying on lists of variable names using macros. None of this can really be reconfigured to create custom scenarios at runtime.
Note how similar the physics test scenario and the vehicles test scenario is. You can just put if statements around the vehicle-related setup functions to turn the vehicles test scenario into the physics test scenario conditionally. It's possible to make one mega-sized scenario instead individual physics/vehicle/universe ones, but this can probably be done in a smarter way for the "more finalized product."
Ideally, the interface for this would be to just list off sessions, and dependencies will be automatically resolved. To do this, we need some way to identify/lookup sessions and determine what dependencies they require (make a trait system :3). A bunch of tables of data works for sure, but part of the problem is how to write a nice interface around it.
This might require a full rewrite of all the session stuff. Sessions are just groups of tasks, data, and pipelines, and maybe dealing with them individually 'per-scene' and removing sessions entirely may be easier for an automated solution. we'll see.
Here's a bit of a mockup of a new interface I'm planning:
Before
src/testapp/identifiers.h:
#define TESTAPP_DATA_PHYS_SHAPES 1, \
idPhysShapes
struct PlPhysShapes
{
PipelineDef<EStgIntr> spawnRequest {"spawnRequest - Spawned shapes"};
PipelineDef<EStgIntr> spawnedEnts {"spawnedEnts"};
PipelineDef<EStgRevd> ownedEnts {"ownedEnts"};
};
src/testapp/sessions/physics.cpp:
Session setup_phys_shapes(
TopTaskBuilder& rBuilder,
ArrayView<entt::any> const topData,
Session const& scene,
Session const& commonScene,
Session const& physics,
MaterialId const materialId)
{
OSP_DECLARE_GET_DATA_IDS(commonScene, TESTAPP_DATA_COMMON_SCENE);
OSP_DECLARE_GET_DATA_IDS(physics, TESTAPP_DATA_PHYSICS);
auto const tgScn = scene .get_pipelines<PlScene>();
auto const tgCS = commonScene .get_pipelines<PlCommonScene>();
auto const tgPhy = physics .get_pipelines<PlPhysics>();
Session out;
OSP_DECLARE_CREATE_DATA_IDS(out, topData, TESTAPP_DATA_PHYS_SHAPES);
auto const tgShSp = out.create_pipelines<PlPhysShapes>(rBuilder);
rBuilder.pipeline(tgShSp.spawnRequest) .parent(tgScn.update);
rBuilder.pipeline(tgShSp.spawnedEnts) .parent(tgScn.update);
rBuilder.pipeline(tgShSp.ownedEnts) .parent(tgScn.update);
top_emplace< ACtxPhysShapes > (topData, idPhysShapes, ACtxPhysShapes{ .m_materialId = materialId });
rBuilder.task()
.name ("Schedule Shape spawn")
.schedules ({tgShSp.spawnRequest(Schedule_)})
.sync_with ({tgScn.update(Run)})
.push_to (out.m_tasks)
.args ({ idPhysShapes })
.func([] (ACtxPhysShapes& rPhysShapes) noexcept -> TaskActions
{
return rPhysShapes.m_spawnRequest.empty() ? TaskAction::Cancel : TaskActions{};
});
// ...
src/testapp/scenarios.cpp:
#define SCENE_SESSIONS scene, commonScene, physics, physShapes, droppers, bounds, newton, nwtGravSet, nwtGrav, physShapesNwt
#define RENDERER_SESSIONS sceneRenderer, magnumScene, cameraCtrl, cameraFree, shVisual, shFlat, shPhong, camThrow, shapeDraw, cursor
using namespace testapp::scenes;
auto const defaultPkg = rTestApp.m_defaultPkg;
auto const application = rTestApp.m_application;
auto & rTopData = rTestApp.m_topData;
TopTaskBuilder builder{rTestApp.m_tasks, rTestApp.m_scene.m_edges, rTestApp.m_taskData};
auto & [SCENE_SESSIONS] = resize_then_unpack<10>(rTestApp.m_scene.m_sessions);
// Compose together lots of Sessions
scene = setup_scene (builder, rTopData, application);
commonScene = setup_common_scene (builder, rTopData, scene, application, defaultPkg);
physics = setup_physics (builder, rTopData, scene, commonScene);
physShapes = setup_phys_shapes (builder, rTopData, scene, commonScene, physics, sc_matPhong);
// ...
- I guess there's some benefits to manually specifying all dependencies between sessions/features, but is rather cumbersome.
After
- Renaming "Sessions" -> "Feature".
- These are originally called "sessions" because it's something that isn't really a single 'process' that opens, does something, interacts with other things, then closes. This terminology is similar to how something like a web browser session uses an HTTP session which uses a TCP session. Switching the terminology is like saying "a feature is a session" instead of a "session is a feature."
- Do lots of stuff to reduce the stupid amount of boilerplate required.
src/testapp/identifiers.h:
struct FIPhysShapes
{
struct DataIds
{
TopDataId physShapes;
};
struct Pipelines
{
PipelineDef<EStgIntr> spawnRequest {"spawnRequest - Spawned shapes"};
PipelineDef<EStgIntr> spawnedEnts {"spawnedEnts"};
PipelineDef<EStgRevd> ownedEnts {"ownedEnts"};
};
};
- FI for "Feature Interface"
- Prevents Features from DIRECTLY depending on each other. (FI adds an extra level of indirection. Fundamental theorem of software engineering fixes everything)
- One feature would implement_interface a FI, so that other features can use_interface it.
- Features can implement multiple FIs, but each FI can only be implemented by one Feature.
- Of course this is inspired by Rust traits in some way.
src/testapp/sessions/physics.cpp:
void ft_phys_shapes( FeatureBuilder fb )
{
fb.name("Physics Shapes");
auto [diScn, plScn] = fb.use_interface<FIScene>();
auto [diCS, plCS] = fb.use_interface<FICommonScene>();
auto [diPhy, plPhy] = fb.use_interface<FIGenericPhysics>();
auto [dPhSh, lPhSh] = fb.implement_interface<FIPhysShapes>();
fb.pipeline(lPhSh.spawnRequest) .parent(plScn.update);
fb.pipeline(lPhSh.spawnedEnts) .parent(plScn.update);
fb.pipeline(lPhSh.ownedEnts) .parent(plScn.update);
fb.data(dPhSh.physShapes).emplace< ACtxPhysShapes >( ACtxPhysShapes{ .m_materialId = materialId } );
fb.task()
.name ("Schedule Shape spawn")
.schedules ({lPhSh.spawnRequest(Schedule_)})
.sync_with ({plScn.update(Run)})
.args ({ dPhSh.physShapes })
.func([] (ACtxPhysShapes& rPhysShapes) noexcept -> TaskActions
{
return rPhysShapes.m_spawnRequest.empty() ? TaskAction::Cancel : TaskActions{};
});
// ...
- Features are created at runtime. This ensures there's still ways to make them dynamically loadable or through a scripting language or whatnot.
- Some error handling is preferred that isn't exceptions nor aborting.
-
materialIdhere doesn't have a definition. I haven't quite figured out the 'best' way to pass config data down to feature setups. - Might change this to allow specifying dependencies to Feature Interfaces elsewhere, so they're accessible before this function is even called. Haven't quite figured out the least-ugly way to do this though.
src/testapp/scenarios.cpp:
auto ctxBuilder = rTestApp.builder().create_context();
rTopData.m_currentScene = ctxBuilder.context_id();
ctxBuilder.add_feature(&ft_scene);
ctxBuilder.add_feature(&ft_scene_common);
ctxBuilder.add_feature(&ft_physics);
ctxBuilder.add_feature(&ft_phys_shapes);
// ...
- Oh thank god
- "context" is used to differentiate between different scenes. If there was two scenes, we'd want to specify which one physics shapes is added to.
putting this here to not be so dependent on discord.
A couple iterations later: https://godbolt.org/z/Gb3GvPqar
C++ metaprogramming is inherently a little cursed but that's that.
I'm not expecting many people to understand how the template nonsense works (I put quite a bit of effort into making it actually readable, though it would be a fun challenge to dive into).
I mostly care about the user code below, which can drastically reduce the amount of boilerplate in testapp.
Calls to fb.use_interface<...> and fb.implement_interface<...> from the previous comment won't be needed anymore as function arguments are instead read to determine dependencies:
auto const gc_ftrPhysShapes = feature_def([] (FeatureBuilder& rBuilder, Implement<FIPhysShapes> phySh, DependOn<FIScene> scn, DependOn<FIGenericPhysics> phys)
{
// Initialize values of TopData (top_emplace),
// setup pipelines (rBuilder.pipeline(...).parent(...)),
// and setup tasks here
});
quoting my previous comment:
allow specifying dependencies to Feature Interfaces elsewhere, so they're accessible before this function is even called
https://github.com/TheOpenSpaceProgram/osp-magnum/pull/298