3D graphics test

Controls: Use the A button to turn on a framerate display, B button to switch rendering modes, and the joystick to move the light direction.

I wanted to take a stab at simple 3D rendering on Arcade hardware, inspired by @eanders’s 3D Renderer Demo and @charliegregg’s My 3D Rendering Code.

This obviously isn’t what the hardware is intended for, but I was curious if it’s possible to reach passable framerates. On a PyGamer, I’m seeing 30-40fps with dithering, and 40-50fps with flat colors. This drops to 14/20fps when adding a full-screen background, so it seems to be limited by the pixel fill speed. I’m using scene.backgroundImage().setRows which was the fastest approach I’d found, along with avoiding floating-point arithmetic.

I don’t think this would be usable for full 3D scenery or models with more than a few dozen triangles, though it could be good enough for some very simple blocky spaceships similar to the original 8-bit Elite. (It had flat-shaded ports for Amiga and DOS PCs that replaced the wireframe graphics.)

At this point the code can do basic 3D transforms including perspective and backplane culling, but doesn’t do 3D occlusion, so applications need to use the painter’s algorithm and draw objects back to front. I was thinking about adding a scanline-based Z buffer, but I’m pretty sure it would be too slow to be useful since any code added to the inner pixel loop tends to tank the framerate.

I haven’t implemented clipping yet, so objects extending beyond the screen edges won’t be drawn correctly. This would be fairly straightforward to fix, I just didn’t get around to it.

14 Likes

1 Like

The emulator has a limit of 50fps, but it shows the theoretical framerate thar it could have reached without that limit. In this case, it’s using less than half of the per-frame time available and could easily add more polygons.

It’s easier to get high framerate in the emulator than on hardware, for example floating point math is about the same speed as integer math on a PC browser, but a lot slower on arcade hardware.

This reminds me, I couldn’t get the stats display from the device menu working on my PyGamer - does it need extra steps or special setup to show that output?

Go to menu
Select the watch
SmartSelect_20210220-132628_Samsung Internet
And it displays stats!

1 Like

That works for me in the emulator, same as in your screenshot, but on my PyGamer hardware that menu function doesn’t seem to do anything, I don’t see any stats being displayed. That’s why I added an extra framerate display in the app, but I’m wondering if I’m doing something wrong. Does it work for you?

Don’t have one, but I wish I had one. I don’t have any hardware at all :sob:

Yeah I’m not sure about anything like this…
I implemented as many optimisations as I could find after stopping posting on My 3D Ren…
the best fps I could get while rendering 500 ish cubes was 15 so not very practical.

Still… It’s kinda fun to play around.

(Keep in mind this is without collisions! You can only break or place blocks, like Minecraft.)

1 Like

That’s actually quite a bit better than what I’d expect for running on embedded-class hardware, and 15fps isn’t all that bad. I think many early 8-bit 3D games were only getting single-digit frame rates.

I agree that this doesn’t seem very useful for full 3D rendered scenes, especially scenery that could have a lot of overdraw such as Minecraft, but I think it may work well enough for some special cases such as a space shooter with a small number of objects that don’t cover too much of the screen. I’ve been experimenting with a fairly lightweight starfield backdrop to go along with this, and initial results look somewhat promising. If I find time I’ll keep poking at it to see what I end up with.

ico-stars

4 Likes

image

This broke my brain

Sorry about that. The starfield movement wasn’t properly synchronized with the viewpoint movement yet when I recorded that animation, so the motion looked rather odd.

Here’s a fixed example with movement controls. Use the left stick for pitch and yaw, and tilt the device left/right to roll. The A button toggles speed, cycling through forward/backward/stopped. In the emulator, moving the mouse left/right simulates device tilt.

4 Likes

The emulator’s tilt controls aren’t really usable on a phone or tablet, so here’s an alternate version which doesn’t use the accelerometer. Use the B button to select the stick left/right movement function, it switches between yaw and roll control. (Stick up/down is still pitch, button A cycles speed.)

(As a side note for the Makecode Arcade team: would it make sense to use the accelerometer sensor API in the emulator to take advantage of a mobile device’s accelerometer where available? The current touch-based method makes it pretty much impossible to use the onscreen joystick and buttons at the same time as tilt controls.)

2 Likes

I believe we had some quick look at that and were a bit worried it might have significant battery draining perf, but it might also just have been that we talked about it and forgot to implement it! It’s definitely possible / we should probably file an issue and track / investigate :slight_smile:

1 Like

Overdraw?
I looked up that term and I’m not sure I understand it.
Why would you need to draw the same pixel twice?

He means updating how the scene looks like. Correct me If I am wrong @kwx.

Hmmm… Well I’ve never actually looked at a ‘real’ renderer so my method might be bad. I store things as grouped objects when they touch. So it automatically culls everything behind it so I draw front to back. As far as I am aware it’s normally back to front.

Yes, this depends on the type of renderer. If you have a special-purpose renderer, for example a raycaster which finds the closest wall for each Y pixel column, you only need to draw exactly the pixels for that single wall. This has limitations, for example you can’t do a “roll” rotation where the walls are no longer vertical, and you typically can’t draw more complex scenes such as bridges or overhangs.

If you have a more general renderer that displays objects which can partially overlap each other, you need to ensure that the occlusion is correct. The “painter’s algorithm” simply draws them back to front, resulting in overdraw. Or you can use a Z buffer or similar technique to determine which pixel needs to be drawn, but that needs more per-pixel calculations which would likely be too slow for the MakeCode Arcade target hardware.

Edit: here’s an example using back-to-front object sorting. This isn’t quite correct for intersecting objects (you can see the image pop a bit when the closest object changes), but works fairly well otherwise.

4 Likes

@kwx, how would I display a cube through the engine, would that even be possible?

See the triangles.ts file in Explorer, that contains the icosahedron (20-sided die) definition used for the asteroids. It defines vertices (corners) in 3D space, and faces that join the vertices. You could modify that to draw cubes instead.

However, the code is quite messy and not really suitable as a general 3D engine. This thread contained a very early version which eventually turned into Space Rocks 3D.

For more geometry variations, see the joke variant Space Rooks 3D from this chess thread which has a chessboard and some rooks created as rotationally-symmetrical pieces: https://makecode.com/_aFKb1UFEjCyC

The latest version of the renderer is in the unfinished Carrier 3D experiment. That version has additional features, for example BSP tree models that don’t need to be convex. It separates models (3D shapes) and instances (copies of that shape with individual locations and orientations), but it’s unfortunately also more complicated.

Unfortunately it’s hard to make an easy-to-use 3D library, especially if it needs to be efficient. Something like OpenGL is already complicated, and recent libraries such as Vulkan or WebGPU need tons of setup code to even draw a simple triangle :-/

Here’s a slightly cleaned-up example using the latest version of my renderer:

That’s specific to the icosahedron (20-sided die) shape, the coordinates of its corners aren’t integer values. You could just replace it with the value 1.618 everywhere and get the same result. By contrast, for cubes you can just use values -1 and 1 for all the coordinates.

These are vertex numbers, not coordinates. The ASCII art diagram for the cube shows where the vertex numbers are located. Each corner has two numbers, the first is z=-1 (far) and the second is z=1 (near). The far left bottom vertex is number 0 with coordinates [-1, -1, -1]. The last face is [4, 5, 7, 6] which is the near face. This starts at the bottom left corner, then bottom right corner, then top right corner, then top left corner.

The reason that the order needs to be counterclockwise is because the renderer only draws a single side of faces. If it’s facing the viewer, the vertices will be counterclockwise when drawn on the screen and it’s visible. If it’s facing away from the viewer, the vertices will appear clockwise on-screen, and it skips drawing it.

class CubeModel extends MeshModelBase {
    //         ^
    //         |y
    //         |
    // 2,6     |      3,7
    //         |
    // --------+--------> x
    //         |
    // 0,4     |      1,5
    //         |
    // +z = front of screen
    static verticesFloat = [
        [-1, -1, -1],
        [1, -1, -1],
        [-1, 1, -1],
        [1, 1, -1],
        [-1, -1, 1],
        [1, -1, 1],
        [-1, 1, 1],
        [1, 1, 1]
    ]

    // Vertex numbers for each face, in counterclockwise order 
    // when viewed from the outside.
    static facesSource = [
        [0, 2, 3, 1],
        [0, 1, 5, 4],
        [1, 3, 7, 5],
        [3, 2, 6, 7],
        [2, 0, 4, 6],
        [4, 5, 7, 6]
    ]
1 Like