An unnamed factory ship game: SDF rendering, because why not #1520
alec-deason
started this conversation in
Devlogs
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
I'm making a little factory building shoot-em-up game and I've been using old school pixel art-ish sprites for the visuals since the beginning. That makes sense for that kind of game but I also love fluid, exotic visuals in 2D games so I decided to spend some time exploring an alternative look.
The technique I've chosen to explore is signed distance field (SDF) rendering. SDFs lend themselves to both crisp vector graphics style imagery as well as crazy fractals-and-tiedye psychedelia. They get used for everything from ultra high quality font renderers, to procedural animation, to fancy 3d lighting. Appealing stuff.
I'll talk about SDFs in general, then some bits about implementing a renderer in Bevy. Runnable example code is available here and I'm only including small snippets in the text here so look there for more context.
What's an SDF?
The idea behind SDFs is a bit different than regular rendering but appealingly simple. At their core all an SDF is is a mathematical function which takes a point in space and returns the distance between that point and the surface of some object, with negative distances indicating the point is inside the object. They are often very simple, for example here is the sdf for a circle centered at the origin:
Given that function you can draw a circle by feeding each pixel location in and coloring the pixel if the result is negative. There are similar primitive functions for many geometric shapes, check out Inigo Quilez's extensive list of functions for more.
SDFs can be easily composed together to make complex objects and even entire scenes. The min of two SDFs is the equivalent of unioning the shapes, while the max is the equivalent of xor. For example if you were rendering a particle effect you might wrap the circle_sdf function above in another function that transforms the query point to account for the position of a single particle. You could then take the min of that composed function evaluated once for each particle. Something like this:
Once you have an SDF that represents an object it can be manipulated in various ways by perturbing the input point before the function is checked, for example adding a small amount of noise to give the shape some random variation. The flexibility and expressively of the technique is kind of astonishing, for a really nice example check out the work of Inigo Quilez, who is a master of SDFs and has been advocating the technique for years.
But how?
How do you use this to actually render a game? That's where things start to get a bit trickier. A pure SDF renderer doesn't have any of the things a normal renderer does; no meshes, no textures. Instead all you have is (in the extreme case) a single, very complex function that represents your entire scene and which you will need to evaluate at least once for every pixel on the screen.
Actually evaluating these huge, gnarly functions every pixel is too expensive to do in realtime so you have to simplify. As far as I can tell the standard approach is to split the screen into tiles and within each tile only consider the subset of the function that is relevant there which makes things much more tractable. That's the approach I take here.
To do this I build up all the information the tiles need to render on the CPU, pack it into storage buffers on the GPU and then dispatch a single draw call that actually does the rendering. The most important thing the renderer needs to know is which objects are relevant to which tiles.
I approach this problem by building a spatial index of my renderable objects and then recording the N closest ones to each tile. N is a thing you have to tune based on the size of your objects and how exactly you're rendereing them.
Once I know which objects are relevant to which tiles I pack all that information into the buffers which I send to the GPU. Bevy provides this functionality as part of the
RenderResources
derive. Structs which derive RenderResources can be used to send both regular uniforms and storage buffers to the GPU. The fields which should be treated as storage buffers are annotated with#[render_resources(buffer)]
. For example, here is the struct that my renderer uses:The first two attributes will be exposed to the GPU as scalar uniforms but the final three will be storage buffers containing data about the SDFs. On the shader side that looks like this:
The most awkward part of this whole scheme is that bit where on the rust side
functions
is aVec<f32>
and on the glsl side it's an array of structs. I'm carefully building the vec in rust to match the structure of the struct in glsl, including alignment and padding. I suspect there may be a better way to do that in Bevy without the manual fiddling but I haven't figured it out.The last slightly exotic thing that I'm doing is I'm rendering without any mesh because the vertices of the tiles can be trivially calculated by my vertex shader. To do that I don't attach a
Handle<Mesh>
to the entity that has myDraw
component. Instead I retrieve theDraw
in a system and calldraw.draw(0..(PANEL_WIDTH * PANEL_HEIGHT * 6) as u32, 0..1)
on it which tells the GPU to render the right number of vertices (six for each tile, ie. two triangles that make a quad) without telling it what the vertices are. Then the shader usesgl_VertexIndex
to calculate the actual vertex positions in clip space.Here's an example of the kind of rendering you might do. The "terrain" is a more complex material which uses not just the boundary of the SDFs but also the distance from the boundary in the interior and a lot of noise. The ship is rendered using just simple geometric shapes with no fancy effects or elaboration (yet).
Thoughts and future work
I find this technique really appealing but it's a bit weird. It works around the standard GPU pipeline in awkward ways and I suspect that dooms it to have performance problems. My current implementation chugs along at a not exactly breathtaking pace of 40fps on my integrated GPU with a moderately complex scene. But that's without any attempt at optimization and and it's not terrible, especially since my development system is about the least capable graphics hardware I intend to support. But it could be better, the equivalent scene with standard sprites should be able to render at 100+fps. On the other hand, now my rendering is all flattened into one big fragment shader which makes neat effects like glows, complex screen shakes or weird noise based distortions all pretty easy.
About the example code
I stripped out some features that made things more complex and aren't really fully baked in my implementation but which you'd probably want in a real renderer, particularly z-ordering of objects and multiple materials. If I come up with implementations of those that I like better I'll write about them later.
Beta Was this translation helpful? Give feedback.
All reactions