Log In  

Instant 3D plus!

Instant 3D! was a random idea, quickly thrown together to see if it was possible. But after seeing the cool things people can do with it, I wanted to clean it up properly, and also present some of the internal functions more cleanly.

Making the 3D functions more accessible means:

  1. You can often get your game working correctly in 3D even if the Instant 3D "magic" doesn't work correctly, by calling the 3D spr/map functions directly with the right parameters.
  2. You can do things that the original Instant 3D can't do, like having objects that hover into the air.

I've added a little tutorial of converting a 2D game to 3D to illustrate how this works, at the bottom of this post.

Obviously this "snippet" is still very limited, compared to a general purpose 3D library say. You can't use it to create an FPS or a flight simulator. But I think it's a lot easier to use - start with a 2D game, drop it in, and fix up the bits that don't come out right. And you can still do some cool looking 3D stuff with it.

Here's the updated snippet:

-- instant 3d+!

do
 -- parameters
 p3d={
  vanish={x=64,y=0}, -- vanishing pt
  d=128,             -- screen dist in pixels
  near=1,            -- near plane z
  camyoff=32,        -- added to cam y pos
  camheight=32       -- camera height
 }

 -- save 2d versions
 map2d,spr2d,sspr2d,pset2d,camera2d=map,spr,sspr,pset,camera

 -- 3d camera position
 local cam={x=0,y=0,z=0}

 -- is 3d mode enabled?
 is3d=false

 -- helper functions

 -- screen to camera space
 local function s2c(x,y,z)
  return x-cam.x,y-cam.y,z-cam.z
 end

 -- perspective projection
 local function proj(x,y,z)
  if -y>=p3d.near then
   local scale=p3d.d/-y
   return x*scale+p3d.vanish.x,-z*scale+p3d.vanish.y,scale
  end
 end

 -- screen to projected
 local function s2p(x,y,z)
  local x,y,z=s2c(x,y,z)
  return proj(x,y,z)
 end

 -- 3d drawing fns
 function sspr3d(sx,sy,sw,sh,x,y,z,w,h,fx,fy)
  w=w or sw
  h=h or sh
  local px,py,scale=s2p(x,y,z)

  if(not scale)return
  local pw,ph=w*scale,h*scale

  -- sub pixel stuff
  local x0,x1=flr(px),flr(px+pw)
  local y0,y1=flr(py),flr(py+ph)
  sspr2d(sx,sy,sw,sh,x0,y0,x1-x0,y1-y0,fx,fy)
 end

 spr3d=function(n,x,y,z,w,h,fx,fy)
  if(not z)return
  -- convert to equivalent sspr() call
  w=(w or 1)*8
  h=(h or 1)*8
  local sx,sy=flr(n%16)*8,flr(n/16)*8
  sspr3d(sx,sy,w,h,x,y,z,w,h,fx,fy)
 end 

 function map3d(cx,cy,x,y,z,w,h,lyr)
  if(not h)return

  -- near/far corners
  local fx,fy,fz=s2c(x,y,z)
  local nx,ny,nz=s2c(x,y+h*8,z)

  -- clip
  ny=min(ny,-p3d.near)
  if(fy>=ny)return

  -- project
  local npx,npy,nscale=proj(nx,ny,nz)
  local fpx,fpy,fscale=proj(fx,fy,fz)

  if npy<fpy then
   local tx,ty,ts=npx,npy,nscale
   npx,npy,nscale=fpx,fpy,fscale
   fpx,fpy,fscale=tx,ty,ts
  end

  -- clamp
  npy=min(npy,128)
  fpy=max(fpy,0)

  -- rasterise
  local py=flr(npy)
  while py>=fpy do

   -- floor plane intercept
   local g=(py-p3d.vanish.y)/p3d.d
   local d=-nz/g  

   -- map coords
   local mx,my=cx,(-fy-d)/8+cy

   -- project to get left/right
   local lpx,lpy,lscale=proj(nx,-d,nz)
   local rpx,rpy,rscale=proj(nx+w*8,-d,nz)

   -- delta x
   local dx=w/(rpx-lpx)

   -- sub-pixel correction
   local l,r=flr(lpx+0.5)+1,flr(rpx+0.5)
   mx+=(l-lpx)*dx

   -- render
   tline(l,py,r,py,mx,my,dx,0,lyr)

   py-=1
  end 
 end 

 function map3dupright(cx,cy,x,y,z,w,h,lyr)
  if(not h)return
  local px,py,scale=s2p(x,y,z)
  if(not scale)return

  local pw,ph=w*8*scale,h*8*scale

  -- texture step
  local dx,dy=w/pw,h/ph
  local mx,my=cx+0.0625,cy+0.0625

  -- sub pixel stuff
  local x0,x1=flr(px),flr(px+pw)
  local y0,y1=flr(py),flr(py+ph)
  mx+=(x0-px)*dx
  my+=(y0-py)*dy  

  if(x0>=x1 or y0>=y1)return

  for y=y0,y1-1 do
   tline(x0,y,x1,y,mx,my,dx,0,lyr)
   my+=dy
  end
 end

 function camera3d(x,y,z)
  cam.x,cam.y,cam.z=x,y,z
 end

 -- "instant 3d" wrapper functions
 local function icamera(x,y)
  cam.x=(x or 0)+64
  cam.y=(y or 0)+128+p3d.camyoff
  cam.z=p3d.camheight
 end

 local function isspr(sx,sy,sw,sh,x,y,w,h,fx,fy)
  z=h or sh
  y+=z
  sspr3d(sx,sy,sw,sh,x,y,z,w,h,fx,fy)
 end

 local function ispr(n,x,y,w,h,fx,fy)
  z=(h or 1)*8
  y+=z
  spr3d(n,x,y,z,w,h,fx,fy)
 end

 local function imap(cx,cy,x,y,w,h,lyr)
  cx=cx or 0
  cy=cy or 0
  x=x or 0
  y=y or 0
  w=w or 128
  h=h or 64
  map3d(cx,cy,x,y,0,w,h,lyr)
 end

 function go3d()
  camera,sspr,spr,map=icamera,isspr,ispr,imap
  camera2d()
  is3d=true
 end

 function go2d()
  map,spr,sspr,pset,camera=map2d,spr2d,sspr2d,pset2d,camera2d
  is3d=false
 end

 -- defaults
 icamera()
end

-- enable 3d mode
go3d()
menuitem(3,"3d",go3d)
menuitem(2,"2d",go2d)

As before, to use it, just copy it into your 2D program. It should behave exactly the same.
There's one new feature, in that you can toggle between 2D and 3D in the Pico-8 menu.

You can also do it in code using

go2d()

and

go3d()

which might be useful for 2D title screens etc.

Taking it further

The basics are the same as before, but you can now - with a little bit of work - take your games a bit further by making use of explicit 2D and 3D commands, rather than leaving it up to the snippet to guess your intent.

To illustrate this, I made a little 2D cart to convert into 3D.

Cart #instant3dplus-0 | 2020-05-16 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA
45

This is a simple little game where you're a bouncing ball that collects coins. It's not finished, but is enough to demonstrate the process. The game is already a little bit 3D in that the ball and coins also have a height, and objects can move in front and behind each other, so it has to sort their positions and draw them from back to front.

Dropping the snippet into this program has... mixed results.

As you can see, it's kind of 3D, but it has some issues:

  • The ball doesn't always bounce straight up. In fact if you look closely it's actually staying on the ground and just bounces away and back again.
  • The coins aren't raised up properly either.
  • The metallic struts are flat on the ground, rather than standing up.
  • Likewise the gratings on top are also flat on the ground.
  • The 3 lives are displayed in the wrong place.

Obviously the snippet doesn't know exactly what we're trying to achieve, but fortunately we can help it out.

With a little bit of work we can make it look like this:

3D functions

We can fix up the ball using an explicit 3D function. The snippet provides explicit 3D functions spr3d, sspr3d and map3d. They have the same parameters as the standard functions, except there's a Z parameter immediately after the X and Y screen parameters.

They use a 3D coordinate system where:

  • The X axis is to the right
  • The Y axis is out of the screen (towards you)
  • The Z axis is up

This keeps X and Y consistent with the "Instant 3D" logic, where instead of moving up the screen as Y decreases, objects move away from you. The new Z parameter allows us to also specify the height.

The ball drawing code looks like this:

  elseif thing.typ=="player" then
   shadowcols()
   sspr(0,32,
      8,8,
     thing.x-4,thing.y-1,
     8,4)
   pal()
   spr(64+thing.frame%9,
       thing.x-4,
       thing.y-thing.height-8)   
  end

The sspr() call draws the shadow, which looks correct already, so it doesn't need to change.
The spr() call draws the ball, based on the thing.x,-.y and -.height variables. The game stores the position of bottom center of the ball, so it has to subtract 4 and 8 to get the top left corner for spr().
We can change it to an explicit 3D call as follows:

   spr3d(64+thing.frame%9,
       thing.x-4,
       thing.y,
       thing.height+8)   

We're still specifying the top left corner, but now it's in 3D.

The coin code is similar:

  if thing.typ=="coin" then
   shadowcols()
   sspr(0,40,
        8,8,
        thing.x-4,thing.y-1,
        8,4)
   pal()
   spr(80,
       thing.x-4,thing.y-thing.height-8) 

Once again we change the spr call to an spr3d:

   spr3d(80,
       thing.x-4,
       thing.y,
       thing.height+8) 

With 3D positions supplied, the ball and coins now bounce/float above the ground properly.

Upright maps

Next we can address the red metal structs. Currently they are lying flat on the ground instead of standing up straight.
The "instant 3D" snippet assumes everything drawn with "spr"/"sspr" is upright, and everything drawn with "map" is flat on the ground. However the struts are drawn using map(), so that they can be composed of multiple sprites.
So we need to tell the game to draw them upright.

map3d won't help in this case. We can use it to draw them higher up, but they will still be lying flat, not standing upright. So to help with this the snippet provides an alternative "map3dupright" function.

The strut drawing code is:

  elseif thing.typ=="strut" then
   map(127,0,
       thing.x,thing.y-40,
       1,5)

We can change the map call to a map3dupright like this:

   map3dupright(127,0,
       thing.x,
       thing.y,
       40,
       1,5)

Once again it has the same parameters as "map", except there's a Z parameter immediately after the X and Y.
The struts are 40 pixels high, so we set the Z (i.e. the height) to 40, to specify the top left corner position.

With this in place, the struts now stand upright.

Cart #instant3dplus-1 | 2020-05-16 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA
45

3D parameters

Now that objects are above the ground they often disappear above the top of the screen.
This is due to the camera height, and the "vanishing point" of the 3D projection, which is currently set to the top of the screen.

We can easily fix this by changing the 3D parameters.
The default parameters are in the snippet:

 -- parameters
 p3d={
  vanish={x=64,y=0}, -- vanishing pt
  d=128,             -- screen dist in pixels
  near=1,            -- near plane z
  camyoff=32,        -- added to cam y pos
  camheight=32       -- camera height
 }

We can move the vanishing point into the center of the screen by adding a line to the _init function:

 p3d.vanish.y=64

Moving the vanishing point is like rotating the camera upwards slightly. Now we can easily see everything.
In fact we can even move the camera down a little and nearer to the ball:

 p3d.camheight=20
 p3d.camyoff=20

3D map coordinates

Now we'll fix the metal grates. These are supposed to sit on top of the struts.
The grates are rendered as a single map, much like the floor. We need to tell the game to draw that map above the ground.

This time map3d is the correct function to use.

The existing code looks like this.

 -- roof map
 map(32,0,0,-40,16,64)  

The 3D code is quite similar:

 -- roof map
 map3d(32,0,0,0,40,16,64)  

Once again we have a new Z parameter after the X and Y, which we set to 40 to move it up into the air.

In the 2D version the grates are drawn last, as because they are above everything. However in the 3D version they actually need to be drawn before the ball, coins and struts to get the correct ordering.
So the line should be moved up immediately after the "map" call that draws the floor.

2D drawing

The last thing to fix is the lives display. Lives are displayed as 3 balls, but they are drawn in the wrong place, because the "instant 3D" logic is trying to position them in 3D.

In this case we really just want to draw them in 2D.

Fortunately the snippet saves the original 2D functions as "spr2d", "sspr2d" and "map2d", so we can call them directly if we need to.

The life drawing code looks like this:

-- overlay
camera()
fancyprint("lives",4,4,12)
for i=1,player.lives do
 spr(64,25+(i-1)*9,2)
end

Simply change the "spr" to "spr2d" and we're done.

2D/3D code

Adding the various 3D calls makes the game look correct in 3D now. But if you switch it back to 2D (via the pause menu) it now looks broken.

One solution is to simply remove the "menuitem" calls from the snippet and disable mode switching. This is perfectly valid if the game is only supposed to be 3D.

But if you really do want the 2D option, it is still possible. The snippet includes a variable "is3d" which is set to true in 3D mode. Before calling any 3D function, check it to ensure you are actually in 3D mode. If not, perform the original 2D call instead. For example:

if is3d then
 spr3d(64+thing.frame%9,
  thing.x-4,
  thing.y,
  thing.height+8)
else
 spr(64+thing.frame%9,
  thing.x-4,
  thing.y-thing.height-8)    
end

Here's the final cart with 2D and 3D mode support.

Cart #instant3dplus-2 | 2020-05-16 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA
45

Other bits

That's the gist of how to use it. There are a couple of functions I've missed, like camera3d (sets the 3D camera position explicitly, rather than inferring it from the 2D position and height/y-offset parameters), and a pset3d/pset2d which should do what you'd expect.

Feel free to throw me any questions you have.

-Mot

** Update 1: Fix spr() when drawing larger than 1x1 sprites

P#76690 2020-05-17 00:58 ( Edited 2020-05-18 23:56)

This is awesome!

Looks like you forgot to pass the layer arg to tline in map3dupright (~ line 141).

I'm getting some strange behavior with map3dupright where the apparent position seems to wander.

The orange bit of the cave wall corner appears to go up and down when it should be stationary.
The back walls look fine and stay where they should. I didn't have any issue like this when I had
the extra code to create sprites and use the sspr call with your first version.

All z args are set at 8 in the calls. I also had to add 8 to the y arg, which is counter-intuative.
I would have expected the "lifted" map calls to be anchored to the floor and come forward, like folding
paper up towards you. In other words, if I called map3d, its pixels should be covered by a similar call
to map3dupright.

Again, this is awesome! Now I need to design ceiling sprites. I also want a map3duprightsideways so I
can lift map section up to the left and right.

First library for reference

Original code for reference

P#76755 2020-05-17 06:43
:: Mot

@McLeopold whoops, good spotting on the tline.

Not sure whats happening with map3dupright. I made this very crude test and it seems to work for me.

Cart #cavetest-0 | 2020-05-17 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA

I'm happy to look at your code if you want. Maybe there's some combination it doesn't handle properly.

A sideways map3d function should be doable. I'll have a look when I get a change.

P#76760 2020-05-17 10:14
:: merwok
3

This is really cool, thanks for sharing!

A contribution: dynamic menu item

function switch_3d()
 menuitem(2)
 menuitem(2,"switch 3d off",switch_2d)
 go3d()
end

function switch_2d()
 menuitem(2)
 menuitem(2,"switch 3d on",switch_3d)
 go2d()
end

switch_3d()
P#76767 2020-05-17 16:25
:: merwok

I noticed an issue in the new code that wasn’t there with instant3D: I draw 2x2 sprites using spr, with the new version (automatic 3D, I am not calling the new functions explicitly) only the top-left part of the sprite is shown.

P#76808 2020-05-18 01:55
:: Mot

Thanks @merwok. I've fixed the 2x2 sprite issue.

P#76812 2020-05-18 04:18
1

@Mot I found my issue with the map3dupright; Your library works just fine.

I was scanning for map sections with the same light level and using a single map call to draw the section. I was doing this vertically instead of horizontally, so the map3dupright was stacking the sections on top of each other making them appear below the floor level. When moving the character the light levels change, so a few tiles that got lit (but were skipped by the layer filter) threw the apparent height off.

Inverting the nested loop and scanning horizonally fixes it.

P#76817 2020-05-18 07:25
:: merwok

The updated snippet doesn’t show any sprite (1x1 or 2x2, with spr)

P#76865 2020-05-18 22:52
:: Mot

@merwok oops! Sorry, I should have checked it properly.
Should be fixed now.

P#76870 2020-05-19 00:00 ( Edited 2020-05-19 00:01)

great tool love it and want to do something with it.
but if i use sspr normaly i can see through them.
i can see the colors of the other sprites shining through.
can anyone help my false idea of this
thought of a little racing game for a demo
press left to make a new wall that is comming towards you

Cart #juforozode-0 | 2020-05-19 | Code ▽ | Embed ▽ | No License

sorry for my bad english

and thanks again for this great tool my students will love this

P#76896 2020-05-19 12:00
:: merwok

It is fixed!

P#76931 2020-05-20 03:34
:: Mot

Sorry @rmueglitz I only just saw your post.

You need to draw the walls in reverse order, so that they go from back-to-front:

for i=#walls,1,-1 do
 print_wall(walls[i])
end
P#77442 2020-05-31 02:13

That's amazing!!!

P#77462 2020-05-31 21:12

If it were to implement mode 7, like the Pico Karts example, how it will be?

P#78247 2020-06-18 23:07

Hey @Mot how do I add a shadow to a floating sprite? Also How do I make a sprite spin around the y axis? Also the sprite is render using sspr3d

P#78546 2020-06-26 17:21 ( Edited 2020-06-26 17:44)

@flightofsparks To be honest with you, this is pretty much Mode 7. Mode 7 is just a fancy way of displaying tilesheets in a 3D trickery way, ElectricGryphon's approach for PicoKarts is a different way of achieving the same affect. If you're looking for camera control like in PicoKarts, then honestly I'd recommend starting there and messing around with the game. But if you wanted to make a game like an rpg, or 3D platformer, this will end up being a far easier (and less headache inducing) way!

P#79506 2020-07-17 19:12

@Le Dook, it'll be in terms of camera rotation.

P#79855 2020-07-24 14:16

Sick, but Grade 8.5/10, Mot. You put a life meter and didn't have a purpose for it.

P#80059 2020-07-28 22:27

You also did not put code for when the ball hits the strut that it should bounce back. Also, colliding with the metal net. But overall, it was amazing.

P#80061 2020-07-28 23:33

@Mot Any idea how to do the "sideways" walls/sprite suggestion? I would love that feature!
Although even a suggestion on how to combine tline with the perspective functions to get that effect would be great.

P#84696 2020-11-26 02:08
:: Mot

@McLeopold @Jessicatz

I finally got around to doing a "side-on" version of map3d:

    function map3dsideon(cx,cy,x,y,z,w,h,lyr)
        if(not h)return

        -- near/far corners
        local fx,fy,fz=s2c(x,y,z)
        local nx,ny,nz=s2c(x,y+w*8,z)

        -- clip
        ny=min(ny,-p3d.near)
        if(fy>=ny)return

        -- project
        local npx,npy,nscale=proj(nx,ny,nz)
        local fpx,fpy,fscale=proj(fx,fy,fz)

        if npx<fpx then
            local tx,ty,ts=npx,npy,nscale
            npx,npy,nscale=fpx,fpy,fscale
            fpx,fpy,fscale=tx,ty,ts
        end

        -- clamp
        npx=min(npx,128)
        fpx=max(fpx,0)

        -- rasterise
        local px=flr(npx)
        while px>=fpx do

            -- floor plane intercept
            local g=(px-p3d.vanish.x)/p3d.d
            local d=nx/g        

            -- map coords
            local mx,my=(-fy-d)/8+cx,cy

            -- project to get top/bottom
            local tpx,tpy,tscale=proj(nx,-d,nz)
            local bpx,bpy,bscale=proj(nx,-d,nz-h*8)

            -- delta y
            local dy=h/(bpy-tpy)

            -- sub-pixel correction
            local t,b=flr(tpy+0.5)+1,flr(bpy+0.5)
            my+=(t-tpy)*dy

            -- render
            tline(px,t,px,b,mx,my,0,dy,lyr)

            px-=1
        end 
    end

It takes the same parameters as the other map3dXXX functions.
x,y,z corresonds to the position of the top left corner, same as the other fns. h (height) also behaves the same. However because it's rotated 90 degrees, w (width) is now how far towards you it extends.

Here's a simple test cart:

Cart #mot_i3dp_test-1 | 2020-11-29 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA

P#84822 2020-11-29 08:13

awesome, works very well, thanks!

P#84862 2020-11-30 15:37

[Please log in to post a comment]

Follow Lexaloffle:        
Generated 2021-04-16 11:27 | 0.213s | 4194k | Q:136