mach: decide on high-level abstraction API #309
Labels
No labels
CI
all
basisu
blog
bug
build
contributor-friendly
core
correctness
deferred
dev
direct3d-headers
docs
driver-os-issue
duplicate
dxcompiler
editor
examples
experiment
feature-idea
feedback
flac
freetype
gamemode
gkurve
glfw
gpu
gpu-dawn
harfbuzz
help welcome
in-progress
infrastructure
invalid
libmach
linux-audio-headers
long-term
mach
mach.gfx
mach.math
mach.physics
mach.testing
model3d
needs-triage
object
opengl-headers
opus
os/linux
os/macos
os/wasm
os/windows
package-manager
priority
proposal
proposal-accepted
question
roadmap
slipped
stability
sysaudio
sysgpu
sysjs
validating-fix
vulkan-zig-generated
wayland-headers
website
wontfix
wrench
www
x11-headers
xcode-frameworks
zig-update
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
hexops/mach#309
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
This is speaking of the currently in-development
machAPI - what most people will use to build Mach applications.Goals
State of things today
As it stands today, your main program is defined as an
Appstruct. Mach consumes this, defines the main entrypoint and calls your applicationinitfunction and so on. This is the same design from xq's excellent zero-graphics library.We then have:
App-> your applicationmach.Engine-> given to most functions and provides access to core engine functionality (the abstraction)mach.Timer-> cross platform timer that works in wasmengine.gpu_driver.swap_chain-> the swap chainengine.gpu_driver.device-> the actualgpu.InterfaceWebGPU APIengine.core.pollEvent()-> event pollingengine.core.setSizeLimits()-> window abstraction functionsThe callbacks your
Appmain.zigcan define are:pub fn init(app: *App, engine: *mach.Engine) !voidpub fn deinit(app: *App, engine: *mach.Engine) voidpub fn update(app: *App, engine: *mach.Engine) !boolpub fn resize(app: *App, engine: *mach.Engine, width: u32, height: u32) !voidOpen questions / what this issue is about
How should the API look?
I still need to find time to sit down and look into this more deeply, and see if my ideas here are actually practical / applicible or not. On the surface, here are my basic thoughts:
App concerns
I think
pub const optionsinAppis too magical. It can be very confusing to defineconst optionsand not know why the options aren't taking effect (forgotpub.)I think for newcomers, especially those new to Zig,
Appwill be very confusing in general.Mainwould be a better name, but I'm hoping we can find a better way to make the API look more like "pass an App struct to mach" (even if that's not what is actually happening) than "I define an App, it consumes it magically" - I need to look into this.API nesting concerns
Things like
engine.gpu_driver.deviceandengine.core.pollEventare too verbose for my taste. I'm hoping we can reduce these to something more like:engine.gpu_driver.swap_chainengine.swap_chainengine.gpu_driver.deviceengine.gpuengine.core.pollEventengine.pollEventengine.core.setSizeLimitsengine.setSizeLimits(not a concrete proposal, this might introduce other issues I'm not aware of.)
Callback concerns
One thing that I think will add to the confusion between
AppandEngine("which one's mine?", "what's the difference?") is that callbacks get bothapp: *App, engine: *mach.Engineright now. I think if we can reduce this to say justapp: *Appand haveengine: *mach.Enginebe a field ofappthat could be better perhaps.Disclaimer
I really haven't thought out much of this, these are just surface-level thoughts. I want to sit down and review the code more carefully to get a better grasp of how these changes would impact things.
Some things which we need to discuss: What exactly Engine is? Should it just be an interface to access core engine functionality (window, input, events, gpu init etc)? Or should it be a more complex collection of high level engine modules (like Physics, Renderer, Ecs) the collection of which may be influenced by comptime options.
With the first route, we can merge Core and GpuDriver and make Engine the replacement instead. So basically different backends will define their own
Enginewith their platform specific code (ofcouse this makes the name Engine confusing)With the second option we get the advantage that engine features are automatically initialized and deinitialized and is part of one struct. If we DO NOT have it, an application would look like this:
But with this route, such things will be handled automatically (it will be part of Engine, like
engine.ecs,engine.rendererand modules can be included and excluded based on comptime options.One small input to the API: I think setSizeLimits should not be a function but rather a field in mach.Options. It should also be noted that this function does not and cannot do anything for wasm.
We can solve this in different ways. See https://github.com/hexops/mach/blob/main/src/Engine.zig#L84
What about making App.init return a mach.Options?
What you suggested is basically my first option.
This needs to be discussed. Currently Engine is a magical struct and it would remain so. If let say some application dont define Engine as a field in their App, the whole program will basically break in unexpected ways. We can say that Engine is the core of mach right now.
This is a really great question, I can't believe I forgot to touch on this point - sorry!
I think it should just be "an interface to access core engine functionality (window, input, events, gpu init etc)" - with one caveat: it's also the interface to the ECS, which is how you compose everything and pick-and-choose which components of Mach you wish to use.
I have not implemented systems in our ECS yet in any meaningful way, and so I do not have a concrete API to demonstrate this, but I envision something similar to the Bevy engine API which looks largely like this:
Of course, ours would not be
App::new()nor.run(), but the same basic idea behind "I have a place to add systems, and get systems"Ours might look more like:
Where
mach.rendereris an ECS system which discovers entities withRenderercomponents and renders them. All interaction with the renderer is done primarily through the ECS/entity interfaces, then, and only in rare cases do you need to access actual renderer functions.This is my general thinking, not concrete ideas of course. There may be major issues I haven't thought of here.
Makes sense!
This makes sense. Though I suggest that modules should work as much as possible on comptime. We should discuss more about this on ECS channel though.
I dont think we can assume that. People might just want to use a conventional renderer for their project. I think what could be done is to have a base
Renderertype. The ECS Renderer module uses this Renderer type internally. If someone wants, they can get a pointer to it and play around with it as they like*Otherwise, if someone doesn't want to use the ECS Renderer at all, then they can basically initialize a Renderer themselves (as a field of App) as use it however they like.
Of course this whole part doesn't makes sense if we dont want to consider this usecase, but since mach is meant to be both a game engine and an application development framework, I think this should be considered.
Sounds like a good idea.
Overall this approach sounds right to me. I am unsure if having each backend define their own
Enginemakes sense, but I really mean "unsure" here (it may well be the right way to do it.)I don't think the name Engine would be confusing here, after all, the engine has to start somewhere. If not here, then what else would be called "engine"?
I would like this too. Let's chat more about this in the ECS channel for sure.
This sounds generally good to me, but I do want to highlight that I would not be concerned if the only interface to Mach's built in Renderer is in fact via ECS only.
I think that our ECS approach should absolutely be good for an application development framework, not just for games. It may be confusing to hear this since ECS is mostly discussed in the context of games - but I think this can and will be a very valuable thing to have in place when developing regular UI applications.
I also agree that if someone doesn't want to use the builtin Mach renderer, they can of course have their own Renderer themselves as a field of App and use it however they like. Whether or not the builtin Renderer of Mach is suitable for this case, I don't know - but I don't think it would be a primary concern, more a 'nice to have'. This scenario would be more 'I am building my own renderer in WebGPU' I think.
naming
The name
Engineis a well known super high level term. Think of Unreal Engine. Something that gives you full set of tools to quickly build a game.My proposal is to have a core type renamed to
Core(unsurprisingly) and make it hold all the low level apis.Coreprovides input handling, and exposes all the platform independent low level apis. not optional.Engineprovides ECS, physics, etc, is optional.Corealso holds platform specific fields that are never exposed to end user publicly (but might be still given access to if needed).App api
With above naming in mind:
the
Tis fully user defined, and we get it inmachby examininginit- the only required function, basically an entry point for user code.Since
Tis user defined, options can be easily embedded by the user in init, if one wants to. Can be checked by examiningTforOptions.Notice none of the functions return error union, and update does not return bool. Returning bool on update is not exactly descriptive. What about simply having a method
Core.exit()and call if needed, which would calldeinitif present. No confusion here. As for not returning errors - i envision no way on how a high level abstraction thatAppis supposed to be can fail on init or update. If they happen, there is no need to tellmachabout it though, and user can simply crash manually or exit cleanly with an error throughCore.exit()or maybeCore.exitWithError(), since if they are not recoverable by theAppdesigner,machcan not do absolutely anything about those anyway.The optional nature of
updateallows user wanting to go low level gpu-only approach to defineupdateand do everything manually from there, while high level users will avoid it and get fullmach, something like:actually can even give the user a way to indicate high or low level of
machby asking to provide eitherinit(core: *mach.Core)orinit(engine: *mach.Engine)but not bothIn my last proposal of returning Options from Mach, I missed an important point that we are using Engine in init itself, and in future we would need to too. So back to drawing board.
api must follow principle of least astonishment.
now
proposal
those things are not directly related in code, but they are about the same subject, so must be more structurally unified. and additionally nesting is way distracting, and discoverability of methods suffer too.
Also, as to that non-null dereference. Any objection if i try refactoring to get rid of it internally?
We can safely remove bool from update(), I think I added it for recursion protection in wasm, but it doesnt happens in case of requestAnimationFrame,
Making update() optional is possible as with ECS, we wouldn't need this anyways, But having deinit() as optional is a bit tricky. First of all. in case of a single callback function (having just init) it means that it will have all the code including its internal deinits inside defer blocks. That means we cannot expect the program to run properly after init has returned. So we would need a new function called run() maybe inside Engine which will run the main update loop. But if update() is present, this function will be called implictly by the core engine systems.
This basically means that we can go for two routes: 1) have only init() OR 2) have atleast init(), deinit()
(1) gives the feeling of a more traditional application entry point, it is basically us recreation main() on top of main() (yes sounds a bit weird). We can also get rid of *App param in init() with (1)
(2) is similar to how we do things today and is less complex but *App param is necessary.
In any case we will have to have (2). So the question remains is do we want (1)?
Sorry forgot to reply and I worded the initial statement poorly. Currently with Core and GpuDriver, we have an internal field which is defined by different backends. I suggested that we combine Core and GpuDriver and move all of its functionality to Engine directly. So now we will have that
internal: Platform.Typeas a field inside of Engine instead of having it inside of another struct probably called Platform (reducing nesting).The different platforms will define their own
Platform.Type.@iddev5
This sounds generally good to me, but can you elaborate on the
if update() is present, ...part? I'm not sure I fully understand it. I guess you're saying that ifrun()is used ANDupdateis present then.. there's an edge case, and something happens then?I think this is worth doing. It does add more complexity, but giving the feeling of a more traditional entrypoint is a huge win for newcomers and first impressions in general. This sort of thing has untold benefits long term. That's not to say we have to do it today, but I do think it's the right tradeoff to choose here.
yes, basically we cannot have both
run()being used andupdatebeing declared in the same program. Because run would be running a loop and will block from update() being ever called. I don't know how to actually solve this problem though in terms of compile time errors. If we don't provide something there's chance that users would accidentally call run() in their init() while they have an update() defined.EDIT: SOLUTION: We can define run() as following:
Also, the actual internal main would be:
if we have only one entry point, "main on top of main" and give engine to that and leave user decide what api to use.
manual low level way
full mach
also, the
recreatepart might come in handy in the future if we want to support switching adapters on the fly. Think on battery low request low-power adapter. Will also allow to change in game options without restart.@d3m1gd we cannot have only one entry point and have a while (...) loop inside it because wasm and Android doesn't allow that. That's the entire reason why we got custom entry points in the first place.
i see. so, back to the custom entry points then.
since init takes App, we can have another step
setupas in:this allows to fill Options without magic, and is exactly apparent where App comes from, as allocator implies we must allocate it ourselves.
This way there is exactly one mental step to burden users with: "define needed functions with proper signatures", instead of additional "define pub const Options".
If recreating engine live is an option, we could even extend
machSetupto take oldApp, as in:this way user could just recreate engine as a request
on the point of deinit being optional, it doesn't have to be, but if user doesn't want to deinit anything it will just be an empty function serving no real purpose. There is no real technical reason for it to be optional, so it can stay as non-optional, no problem. But overall, we could put such methods as callbacks onto engine itself if needed.
While I like the idea of having a
setup()function, there are some problems with above:So I suggest that setup() should just return a mach.Options, and not an App. It's also better if we could keep it's name to just setup() instead of machSetup()
Recreating engine doesn't need modifications to App. So setup taking oldApp is not needed.
What we can do to facilate recreation is to have a recreate() function in engine taking a mach.Option.
NOTE: we can use this function in init to create the context but init already takes an Engine which is initialized. This means that very closely we are creating-destroying-creating the Engine for no reason. If we keep the initial engine uninitialized, then calling the recreate() function (but this time with a different name like just create()) at the very beginning of init is necessary, otherwise it would let to strange uniitialized behaviors.
Since there has been enough confusion on what App is and we are already considering renaming it, we should rename App to
State. (Name suggestion)See above. We actually went for two routes
I don't like the approach of setting deinit as a callback by a function because if user made an init function they would naturally expect to be able to create a deinit function.
so lets just pass engine uninitialized to init, allowing user to
engine.init(Options), and add checks in every user facing api function to verify they are called afterengine.initwith some flag in engine, but only on dev builds, so those checks wont affect release performance. Relevant error message (as well as documentation) will inform user of necessity ofengine.initso far whatever name we choose
ApporStatewill force user to dopub const State = @This();if there were a way to iterate over all decls in user entry source file (which we know in build.zig App.init), we could find the
fn init(xxx: XXX, engine: Engine)and grabXXXtype and user can choose whatever name they like.I do remember the glfw bindings implement similar checks regarding glfw.init(). Let's see slimsag's opinion in this.
This is not needed. the user can do
const Whatever = @This();first of all we must know what we are gonna write. a library or a framework and toolchain like unity, ue, godot, etc ?
a Library
for some developers this may be preferable because mach apps will be more portable/modular, developer has more control and can use mach as an embeddable module. this means we lose the control to provide wasm target any many more. so developer should provide it's own implemntation
by making mach a library we will need to completely change current function/struct passing API. this has some benefits. expect a mid-level framework that just introduce more problems like custom context/unsafe pointer casts/etc app is the whole context not a part of context.
my windowing entry point
my wasm entry point
a Framework
this way also has some benfits:
since we don't provide a fully high-level API the only remaining way i can though is the current
App -> init(), update(), deinit()API.I would say it's "framework" according to your definition. We want to provide a cross platform easy to use engine for developing apps without worrying about special cases as much as possible.
The "library" approach lacks all the above things. Moreover mach already provided glfw, gpu, freetype, ecs etc libraries to use as per you want. And if needed in future we can convert the wasm code into its own mach-wasm module.
I think @d3m1gd's concerns were mostly addressed by clarifying: regardless of whether we have Engine, Core, or both - neither of those should be aware of physics, gui, etc. in any capacity.
Based on the chat in Matrix, I think we have agreement on the following:
A high-level app would look something like:
Or perhaps
Two remaining questions were "whether or not this unified thing is called "Core" or "Engine", as well as "can I get window/input/webgpu without ECS at all?""
Conclusion: call it
Engineand say no, you always get ECS, but you're free to just not use it (config option.) It's small anyway, and if you don't use it (we can just not callecs.initfor example) it uses no memory due to dead code elimination.@iddev5
Nice, I like this a lot 👍
Honestly, I'm not really sure if I like this better or if I just think it's different. Anyone else have feedback on this?
I agree, inspecting file declarations as an alternative to this sounds even more magical - not what I want.
Tentatively I agree with this, it avoids a serious footgun so let's do it. What might change it is if Zig in the future gets lots of safety features around stack memory misuse.
@iddev5 can you clarify your thoughts around
setup()? I'm having trouble piecing together from this thread how you envisionsetup()could play a role here. Also happy to just say 'do what feels right to you' here, I think you've heard enough of my POV at this point to know which direction we should go in generally here.@alichraghi I would classify Mach as somewhere between your "framework" and "toolchain like unity, ue, godot" - it's a bit hard to explain, but here goes:
I think "framework" heavily implies that you're not getting a one-true-editor experience (the experience you get with unity/ue/godot.) Maybe similar to say raylib, you get excellent standalone tools that you can use with the framework, but not really a unified experience across all of them necessarily.
On the opposite end, I think "toolchain like unity/ue/godot" heavily implies "clunky, large, non-composable" - you do things their way generally, and your application must fit into the unity/ue/godot model. It's not generally supported to say "I want a different GUI, physics engine, renderer, etc." and be able to swap those out in a serious way.
I don't think we need more frameworks in the world, and I don't think we need more unity/ue/godots in the world. What we do need is a better middleground: something that is at least in spirit competitive with unity/ue, because many great games are complex beyond what frameworks can handle, yet composable like a framework. This is only achievable if (1) you have a truly good graphics abstraction everyone can agree on (WebGPU is our golden goose) and (2) you have a truly good interoperability abstraction everyone can agree on (it's my hope ECS will do this for us.) Then you can add a one-true-editor on top which heavily pushes the idea of data-oriented tooling (produce data, like a 3D editor / image editor does, which you consume in your app), a bit of runtime debug tooling, and we're good to go.
We need to decide on how to init
EnginewithOptions. The latest idea is to passEngineallocated but undefined, and let user to calliniton it with options.since we control now how to allocate
Appwe can change as we go back and forth, it is hidden from the user anyway@slimsag Basically what d3m1gd mentioned above. The other option is to have user define a function called setup alongside init and all. The setup() function will return a mach.Options. With this option mach.Engine arg in init is always defined and initialized.
With the first option explained above, the user has to initialize it themselves.
@d3m1gd i did noticed that its as simple as adding a safety check in all functions since engine has fields like device whose methods arent defined inside mach.
Concrete proposal
main(it has to, in wasm main function cannot block.)main.zig'spub fn initto give you an experience somewhat like have amainfunction.Configuration
There are three types of options one can configure Mach with:
build.zigin theAppstruct. e.g. Android app title.glfw.Initoptions.High-level applications
A high-level application (most Mach users) looks like this:
initis available.engine.setOptionsinside yourinit.Startup-only options
If not specified in hard-coded list, defaults are used. Can override with env vars, e.g.
GPU_BACKEND=vulkan.Here
startup_optionswould correspond to a new struct typemach.StartupOptions, which may be empty for now.Supporting runtime changes of things like
power_preference, orrequired_limits, in the currentmach.Optionsstruct may be a bit involved - but we can just leave them inOptionsas-is and document that it's a bug they cannot be changed at runtime for nowLow-level applications
As mentioned earlier, supporting this is an explicit goal:
In this mode, you may still technically have the ECS functions available to you, but they have effectively no impact & will end up mostly eliminated by dead code elimination.
In this mode, your
main.zigmust look like:Each of these are required function signatures, enforced by
@hasFieldand other comptime type checking.What if we make
setupan only non-optional function?// please note, naming is not important here, just the concept
this solution unifies many ideas discussed:
@This()+setup)this is all that is needed for high level mach app.
low-level mach must be explicitly required. which will make mach look for
updateinitdeinitmach internals
After chatting with @d3m1gd we agree on my original proposal above, with a slight clarification:
pub fn init(engine: *Engine) !voidis present -> it's a high-level app, type signature verified at comptimeinitpresent -> helpful comptime error message saying to declare high-levelinitfunctionOnly if you declare
.low_level = trueinbuild.zigAppconfig can you use the low-level API. It then enforces you have the right function signatures at comptime, including: