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
92

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
92

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
92

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

92


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


1

@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
1


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.


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()

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.


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


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.


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


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


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


It is fixed!


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

That's amazing!!!


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


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


@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!


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


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


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.


@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.


@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


awesome, works very well, thanks!


Here's a version with circ, circfill and pset.

-- 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,circ2d,circfill2d,pset2d=map,spr,sspr,pset,camera,circ,circfill,pset

 -- 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 circ3d(x,y,z,r,c)
  if(not r)return
  local px,py,scale=s2p(x,y,z)
  if(scale)circ2d(px,py,scale*r,c)
 end

 function circfill3d(x,y,z,r,c)
  if(not r)return
  local px,py,scale=s2p(x,y,z)
  if(scale)circfill2d(px,py,scale*r,c)
 end

 function pset3d(x,y,z,c)
  if(not z)return
  local px,py,scale=s2p(x,y,z)
  if(scale)pset2d(px,py,c)
 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 icirc(x,y,r,c)
  circ3d(x,y,0,r,c)
 end

 local function icircfill(x,y,r,c)
  circfill3d(x,y,0,r,c)
 end

 local function ipset(x,y,c)
  pset3d(x,y,0,c)
 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,circ,circfill,pset=icamera,isspr,ispr,imap,icirc,icircfill,ipset
  camera2d()
  is3d=true
 end

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

 -- defaults
 icamera()
end

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

Love this and using it in a current project. I'd like to allow different camera angles. i.e., rotate the view 90, 180, or 270 degrees. How difficult would that be and how would you recommend modifying the code?


@bikibird that's a tricky one.
spr, sspr, pset and circ wouldn't be too difficult, even for arbitrary rotation angles. The map functions would be quite a lot more complicated though.
Do you just want to rotate by multiples of 90 degrees?


Yes, Imagine a soccer field and you have cameras pointing at the center from the two sidelines and goals and you want to switch between those 4 views.


@bikibird still sounds a bit tricky.
Can you not perform the rotation in your game code? I.e. create a 90 degree rotated version of the map (assuming you're using one), and position your sprites accordingly - like you would for a 2D game?


I'm doing that for the sprites, but I don't think there's a simple way to rotate a map? I've been hacking your map3d function and making progress, but it's been challenging.


Ah, you mean create duplicate maps for each projection? Well, if I can't get the map3d working the way I want, I may have to go that route.


1

@bikibird I had a play with it and came up with this:

There's a new cameraang function to set the camera angle (in turns, e.g. 0.25 is 90 degrees).

The map drawing isn't perfect. It doesn't rotate the shape of the polygon, just the texture coordinates. So the map can get cut off in some cases. You can work around it by adding space around the outside of your map so you can render a larger than necessary section.

Also it's up to 1365 tokens now, so you might want to trim out some functions if you're not using them (map3dsideon and map3dupright in particular).

-- instant 3d+!

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

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

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

 -- is 3d mode enabled?
 is3d=false

 -- helper functions

 -- screen to camera space
 function rotate(x,y)
  return x*tr[1]+y*tr[3],x*tr[2]+y*tr[4]
 end

 local function rrotate(x,y)
  return x*tr[1]-y*tr[3],-x*tr[2]+y*tr[4]
 end

 local function s2c(x,y,z,norotate)
  x,y,z=x-cam.x,y-cam.y,z-cam.z
  if(not norotate)x,y=rotate(x,y)
  return x,y-p3d.camyoff,z-p3d.camheight
 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 circ3d(x,y,z,r,c)
  if(not r)return
  local px,py,scale=s2p(x,y,z)
  if(scale)circ2d(px,py,scale*r,c)
 end

 function circfill3d(x,y,z,r,c)
  if(not r)return
  local px,py,scale=s2p(x,y,z)
  if(scale)circfill2d(px,py,scale*r,c)
 end

 function pset3d(x,y,z,c)
  if(not z)return
  local px,py,scale=s2p(x,y,z)
  if(scale)pset2d(px,py,c)
 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,true)
  local nx,ny,nz=s2c(x,y+h*8,z,true)

  -- 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)
  local midx,midy=cx+w/2,cy+h/2

  -- 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,dy=w/(rpx-lpx),0

   -- rotate
   dx,dy=rrotate(dx,dy)
   mx-=midx my-=midy
   mx,my=rrotate(mx,my)
   mx+=midx my+=midy

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

   -- render
   tline(l,py,r,py,mx,my,dx,dy,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 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 

 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)+64
  cam.z=0
 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 icirc(x,y,r,c)
  circ3d(x,y,0,r,c)
 end

 local function icircfill(x,y,r,c)
  circfill3d(x,y,0,r,c)
 end

 local function ipset(x,y,c)
  pset3d(x,y,0,c)
 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,circ,circfill,pset=icamera,isspr,ispr,imap,icirc,icircfill,ipset
  camera2d()
  is3d=true
 end

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

 function cameraang(a)
  local s,c=-sin(a or 0),cos(a or 0)
  tr={c,-s,s,c}
 end

 -- defaults
 icamera()
end

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

Superb work, @Mot. Your routine just gets better and better.

The kind of rotation you have here would be good for instance in a 3D racer where you win in 1st place and then the vehicle is placed into auto-drive.

As the vehicle drives the camera pans and rotates directly around the vehicle with "1ST PLACE!" prominently displayed and victory music playing.

It could also rotate with smooth animation stopping at 90 degrees either left or right each time for a classic dungeon crawler when the player turns left or right in the corridors.

My question is in the rotation and focus, can you specify a center point on the map or screen ?


Actually the map might still need a bit of work. I tried it on another game and it was a complete mess!

Might have another go at it later.

The center of rotation is the point that would be drawn in the middle of the screen if it was in 2D mode.

So basically 64 pixels to the right and down from the camera() position.
Or if you use camera3d() that essentially is specifying the center of rotation.


Gorgeous! Thanks for doing this. I hacked a solution specific for my game, but I will keep this in mind for future projects.


this is awesome! i've always wanted to make 3d games, but i always choose 2d engines because every single 3d engine is ultra complex, but this opens up so many more possibilities!


I like this, but pixel sizes don't stay 1 to 1 which makes some things look really bad.


Every time I try and download the carts to look at the code it always gives me the same cart without the 3D? Is this a glitch in the Post or something? Besides that the 3D snippet works great! Is there any way perhaps to make the girder popup effect a sprite flag? I'm sorry if I'm missing something I'm still relatively new to PICO 8, but some help would be great!


1

Try:

load #instant3dplus-2

The cart at the top of the page is the initial 2D version before conversion.
The converted cart at the bottom is #instant3dplus-2


Thanks, @Mot everything seems to work fine!


3

Thank you so much for enabling this game

Cart #seekingthebeast3-0 | 2023-10-28 | Code ▽ | Embed ▽ | No License
23


whenever i use camera3d the screen goes blank

function _init()
p1x=0
p1y=0
p1z=0
end

function _draw()
cls()
camera3d(p1x-60,p1y-60,0)
map()
spr3d(16,p1x,p1y,p1z)
end


Hi @my_name_is_doof. Try this:

function _init()
	p1x=0
	p1y=0
	p1z=0
end

function _draw()
	cls()
	camera3d(p1x,p1y+60,32)
	map()
	spr3d(16,p1x,p1y,p1z)
end

The last parameter to camera3d is the camera height, which needs to be above 0.
Also camera3d() positions the camera exactly where you specify (so don't need to subtract 60 from the x parameter), unlike the regular camera() which assumes the code was originally written for a 2D game and tries to compensate.


thanks @Mot , that worked.


I know this is probably a really dumb question. But why is the whole routine just wrapped in a do loop instead of being run from function _update().

Is this because _update only calls per x frames while the do loop would be called every cycle?


1

@Kryptoid98 not a dumb question.

The "do..end" isn't actually a loop. It just creates a scope. Anything declared as "local" inside the scoped block is only visible to other functions inside said scoped block.

For example the "cam" variable is declared as local as regular code doesn't need to access it directly. Minimising variables that are visible at the global scope makes conflicts less likely (e.g. if your program also had a "cam" variable or function).


Oooh that makes so much sense now, thanks so much!
It has nothing to do with calling it at different intervals then update (if that even makes sense). Its still just drawn in _draw.
Ok cool in my head it was this crazy thing continiously running on top of everything else. But its purely a scope thing. That would explain why I could never find the condition for the 'do-while' loop haha.



[Please log in to post a comment]