bevyengine / bevy

A refreshingly simple data-driven game engine built in Rust
https://bevyengine.org
Apache License 2.0
35.11k stars 3.45k forks source link

Modular, Reactive Gizmos #9498

Open viridia opened 1 year ago

viridia commented 1 year ago

Caveat: the ideas presented here are fairly wild / brainstormy and I don't expect them to be accepted or implemented - but I do want to write them down here because they may inspire discussion.

I'm in the process of porting my game engine and editor from three.js to Bevy. Three.js has about a dozen Gizmos (which are called "Helpers" in three.js terminology), but I don't find them to be particularly useful for my needs. Instead, I've created my own Gizmo framework which has gone through several iterations. I don't use these in the game per se, but they are very helpful in the editor for things like:

...and many others. There are about two dozen custom gizmo types that I use, all of which are based on this common framework.

Note: in the following sections, I'm going to talk about JSX. However, I am not proposing that JSX support be added to Bevy.

A typical gizmo in my three.js system looks like this:

/** Component which displays aspects of fixtures which are normally invisible. */
export const FixtureOutlinesOverlay: VoidComponent<Props> = props => {
  const toolState = useToolState();
  const fixtures = createMemo(() => props.structure?.instances.list().filter(isFixture) ?? []);
  const loader = new TextureLoader();
  const mapPin = loader.load(mapPinImg);

  const spriteColor = (config: IRegionAppearance, selected: boolean) =>
    colord(config.color ?? '#ffdd88')
      .darken(selected ? -0.2 : 0.2)
      .toHex() as ColorRepresentation;

  return (
    <SceneProvider>
      <For each={fixtures()}>
        {fix => (
          <>
            <Show when={fix.aspectConfig(waymark)} keyed>
              {config => (
                <>
                  <FixtureOrientationOverlay
                    fixture={fix}
                    config={config}
                    selected={fix === toolState.selectedInstance}
                  />
                  <Sprite
                    texture={mapPin}
                    location={() => fix.position}
                    scale={[0.25, 0.4, 0.25]}
                    color={spriteColor(config, fix === toolState.selectedInstance)}
                    center={mapPinCenter}
                    opacity={1}
                  />
                </>
              )}
            </Show>
            <Show when={fix.aspectConfig(circleMarker)} keyed>
              {config => (
                <>
                  <FixtureOrientationOverlay
                    fixture={fix}
                    config={config}
                    selected={fix === toolState.selectedInstance}
                  />
                  <FlatCircle
                    location={() => fix.position}
                    radius={fix.ensureProperties<ICircularRegionProps>().radius}
                    color={config.color as ColorRepresentation}
                    opacity={fix === toolState.selectedInstance ? 1 : 0.2}
                  />
                  <Sprite
                    texture={mapPin}
                    location={() => fix.position}
                    scale={[0.25, 0.4, 0.25]}
                    color={spriteColor(config, fix === toolState.selectedInstance)}
                    center={mapPinCenter}
                    opacity={1}
                  />
                </>
              )}
            </Show>
              <Show when={fix.ensureProperties<IWaypointsProps>().waypoints.length > 1}>
                <DashedPolyLine
                  vertices={fix
                    .ensureProperties<IWaypointsProps>()
                    .waypoints.map(wp => wp.position)}
                  dashLength={0.2}
                  lineWidth={0.09}
                  opacity={fix === toolState.selectedInstance ? 0.7 : 0.3}
                  occlusionFactor={0.6}
                />
              </Show>
            </Show>
          </>
        )}
      </For>
    </SceneProvider>
  );
};

And here's a screenshot of what that looks like:

use-marks

Things I want to point out about this code:

For implementing 2D primitives such as circles and rectangles, I have a 2D drawing library which generates meshes. So for example, the DashedPolyLine component calls drawShapes.strokePolyLine(), which fills in a vertex buffer and index buffer. This library also accepts a transform, so for example the Gizmo that draws portal apertures can align the generated rectangle with the portal.

Now, I know that Bevy is not a reactive framework like Leptos. However, it does have the ability to trigger behaviors on change events. So something like what I am describing is not entirely unfeasible, although it would no doubt look very different than the code that I have shown.

alice-i-cecile commented 1 year ago

9400 may also be interesting to you.

viridia commented 1 year ago

9400 may also be interesting to you.

Yes, I saw that. I think that I would rather have a framework for building my own gizmos than have a large set of pre-made gizmos. For example, no one (other than me) is going to provide what I need for editing terrain meshes.

Another thing I am interested in is Unity's "handles" which are gizmos that have clickable / draggable elements. I have not implemented anything like this, but it's the logical next step.

nicopap commented 1 year ago

You still need the primitives like "dashed line", "text", "filled shape" etc. in order to compose them and build more complex shapes/gizmos. Can't really express them in term of just lines. So #9400 is still pertinent.

I'm not sure I follow. I don't understand the bit about modularity. In rust, you'd call gizmo.draw_circle() followed by gizmo.draw_line() to get ­— say — a crossed circle. You could then define a function that takes the Gizmo struct to "compose" gizmos. Now you have a function to draw crossed circles. So composability is already taken care of.

Concerning reactivity/UI system. You aren't the only one asking for it. I've added it now to the #9400 list. I suspect the end result would look more like an immediate mode UI though.

nicopap commented 1 year ago

Note I totally see where you are going with the solidjs-inspired bits. I strongly suspect that dynamic queries would unlock the ability to do that kind of things.

It's not full graph reactivity, because it only goes 1 way, but an ECS + an immediate mode UI is fairly close. You'd build the UI on top of a query and the UI state would be fully coupled to the game state.

viridia commented 1 year ago

Immediate mode is fine. Don't pay too much attention to the syntax, the overall structure is what I'm interested in. I'm only using JSX here because that's convenient in TypeScript, in Rust you'd use something else.

Glad to hear that composability is taken care of.

I should mention something about the Solid "For" construct - it's not just a for loop, it also memoizes. Basically if you have a list of 20 items, and the 10th array item gets changed, only the 10th iteration loop body gets re-run. However, that's just a performance optimization, and may not be necessary for this use case.

Minor point: most of my gizmos use "thick" strokes, that is, strokes made out of triangles, not line primitives. In some cases, I use a hybrid of both - that is translucent filled polygons with line stroked edges. Here's an example showing the navigation mesh gizmo:

mesh1

I won't be insulted if you want to close this issue. I mainly just wanted to get those ideas out there.

viridia commented 1 year ago

I've also thought quite a bit about reactivity, and have come to the conclusion that I need a lot more Rust experience before I'm ready to tackle that issue.

Since Rust doesn't have getters / setters, you can't do what Solid does; but you can have closures. But using closures for every property (color, opacity, line thickness, etc.) produces a lot of clutter and a therefore a bad developer experience. Immediate mode gets around this limitation by throwing away fine-grained updates - you re-build the entire tree every time.

It might be possible to build an API such that you can seamlessly mix closures with constants, using the same kind of impl magic that Bevy uses when you add systems. The result of this would a tree that gets stored in a Local and re-evaluated (but not rebuilt) every update cycle. But I'm a long way from being able to design something like that.

MiniaczQ commented 6 months ago

I think the major difference between this and #9400 is persistency, the current gizmos are immediate and will cause complications if not entirely block interaction.

EDIT after some more reading: