Jump to content

How to make camera view with abstract world units in pixi.js?


SenPie
 Share

Recommended Posts

What I'm trying to accomplish is to basically write all my game logic in world units and use camera to see part of it rendered on the screen.

Let's say my world is an infinite plane and I want to add a sprite, which will have dimensions of one unit in x axis and one unit in y axis. Then, I define a camera with dimensions of 4 units on x axis and 2 units on y axis. Visualization below. example.png.6a3b8dcb68db3c910a369f34f4aba833.png

To get close to my desired result I followed the example in this blog post and used RenderTextureSystem to actually define source canvas dimensions equal to my world dimensions and target dimensions to the browser window size. Here is the code

class WorldContainer extends DisplayObject {
  sortDirty: boolean;

  public content: Container;

  public camWidth = 4;

  public camHeight = 2;

  public camOffsetX = 0;

  public camOffsetY = 0;

  constructor() {
    super();
    this.content = new Container();
  }

  calculateBounds(): void {
    return this.content._calculateCachedBounds();
  }

  removeChild(child: DisplayObject): void {
    this.content.removeChild(child);
  }

  removeChildren(): DisplayObject[] {
    return this.content.removeChildren();
  }

  addChild(child: DisplayObject): DisplayObject {
    return this.content.addChild(child);
  }

  render(renderer: Renderer) {
    const targetWidth = renderer.view.width;
    const targetHeight = renderer.view.height;
    const targetRatio = targetWidth / targetHeight;
    const sourceWidth = this.camWidth;
    const sourceHeight = this.camHeight;
    const sourceRatio = sourceWidth / sourceHeight;
    let requiredWidth = 0;
    let requiredHeight = 0;
    if (sourceRatio >= targetRatio) { // source is wider than target in proportion
      requiredWidth = targetWidth;
      requiredHeight = requiredWidth / sourceRatio;
    } else { // source is higher than target in proportion
      requiredHeight = targetHeight;
      requiredWidth = requiredHeight * sourceRatio;
    }
    renderer.renderTexture.bind(
      null,
      new Rectangle(this.camOffsetX - this.camWidth / 2, -this.camOffsetY + this.camHeight / 2, this.camWidth, this.camHeight),
      new Rectangle((targetWidth - requiredWidth) / 2, (targetHeight - requiredHeight) / 2, requiredWidth, requiredHeight),
    );

    this.content.render(renderer);

    renderer.batch.flush();
    renderer.renderTexture.bind(null);
  }

  updateTransform() {
    super.updateTransform();

    const { _tempDisplayObjectParent: tempDisplayObjectParent } = this.content;
    this.content.parent = tempDisplayObjectParent as Container;
    this.content.updateTransform();
    this.content.parent = null;
  }
}

I have done some extra work to preserve aspect ratio of camera, when drawing to canvas, so it won't get stretched and will just fit the screen.

However, real issue comes when I try to make sprites, because when I give it a texture and let's say dimensions of my texture are 64x64 pixels, then my sprite is huge filling the whole screen. That's when I thought just setting width and height of the sprite should be enough. For example to recreate the example in the picture above, I would make the sprite like this and add it to the content of world container.

const sprite: Sprite = Sprite.from('sadge.png');
sprite.anchor.set(0.5);
sprite.x = 1.5;
sprite.y = 1.5;
sprite.width = 1;
sprite.height = 1;
worldContainer.content.addChild(sprite);

Now, what I don't like about this solution is, that when I add child sprite to that previous sprite and give it's x to be equal to 1, y to be equal to 0, I expect it to appear on second row and third column.  However, not only the child sprite is not in my expected position, it's also not visible. After, hours of debugging I found out, that when setting width and height for parent sprite, under the hood pixi modifies scale and it ruins everything for child sprite. If, I set sprite's width height to be 1x1, it sets scale for sprite to be ( 4 / canvas_w, 2 / canvas_h), which is very tiny and because that scale is also applied to its children, they get so small that they are not visible. I know that I can manually multiply by inverse of the scale ratio for every child and cancel that effect, but to be frank it is very ugly solution.

I was wondering if you could help me to fix this issue, or give me a directing advice on how to approach it. If feels like I am solving this whole world unit issue in a very wrong way and there is far simpler and neater solution. I've been struggling with this thing for weeks now and would really appreciate any help.

Link to comment
Share on other sites

I dont think you need to use separate render texture there.

You could just have your sprites be already in woorld coordinates and then scale the container to desired zoom level and set it's pivot & coordinate to use as your camera.

And if you have a lot of sprites then you should add culling into the mix by just going through all sprites and checking if their bounds are outside of your view and set renderable false/true according to that.

Here's a very simple (untested) example that assumes that 1 unit in your world coordinate is 256px.

function addSprite( worldX, worldY, asset, world ){
  const s = Sprite.from(asset);
  s.x = worldX;
  s.y = worldY;
  s.scale.set(1/256);
  world.addChild(s);
}

const world = new Container();
// Put the world into center of screen.
world.x = renderer.screen.width/2;
world.y = renderer.screen.height/2;
// Show 10 world units on screen. 
world.scale.set(10);

// Create a camera data object that has camera position in world units.
const camera = {x:5, y:10};

// Use pivot point to control camera.
world.pivot.set( camera.x, camera.y );

addSprite(2,4, "monster1.png");
addSprite(8,5, "monster2.png");
addSprite(10,0, "monster3.png");
addSprite(22,14, "monster4.png");

... in some logic update camera position & other game things
... in render loop render the world

 

Link to comment
Share on other sites

Hi @Exca, thank you for reply. I have followed your ideas and code example and made this codepen. However, I'm still not getting quite the desired result. I've added debug graphics rectangle to show area where camera image will be rendered, however all of my contents are not fitting it correctly. I want to make it responsive, so canvas always takes the whole document space, however camera rectangle should fit canvas without changing the aspect ratio. Like in this case, camera cannot fill the  whole canvas, but it fills in horizontal direction.97791736_Layer1.png.60c26c637170fa926a7d1de503d00c65.png

And with that, I correspondingly want to scale the sprites that no matter how you change the window size, you will see the same image from camera with same dimensions.

Link to comment
Share on other sites

If you want to keep the distances inside your world the same you shouldnt change the size of things inside the world. So keep their scale in relation to world and change the viewport (camera) scale. Also if you want to have only a certain area that is visible (while still allowing canvas to go over the red area) add a mask to the viewport. If on the other hand you dont need canvas on the light blue area then you could just limit the canvas element size and have only the red area be rendered saving small bit of performance.

Here's an example based on your codepen. Don't have a codepen account so couldnt create a fork of it:
 

import {
  Application,
  Container,
  Sprite,
  Renderer,
  DisplayObject,
  Graphics,
  Ticker
} from
"https://cdn.skypack.dev/[email protected]";

function resize(renderer: Renderer, world: Container) {
  // Resize the canvas.
  renderer.resize(
    window.visualViewport.width,
    window.visualViewport.height,
  );
  // Center world.
  world.x = app.renderer.screen.width / 2;
  world.y = app.renderer.screen.height / 2;
  
}

const app = new Application({
  width: window.visualViewport.width,
  height: window.visualViewport.height,
  backgroundColor: 0xabcdef,
});

document.body.appendChild(app.view);

const world = new Container();
// Put the world into the center of screen.
world.x = app.renderer.screen.width / 2;
world.y = app.renderer.screen.height / 2;

// Create a camera data object that has camera position in world units.
const camera = { x: 0, y: 0 };

// Use pivot point to control camera.
world.pivot.set(camera.x, camera.y);
const displayObjs: DisplayObject = [];
  
const viewportMask = new Graphics();
// Use a 1x1 mask and scale it to fit.
viewportMask.beginFill(0xffffff,1);
viewportMask.drawRect(0,0,1,1);
viewportMask.endFill();
// Should maybe add a a separate container here instead to allow other stuff on canvas than world & it's viewport.
app.stage.mask = viewportMask;

const utilities = (function Utilities(world: Container) {
  
  function addSprite(worldX: number, worldY: number, asset: string) {
    const sprite: Sprite = Sprite.from(asset);
    sprite.x = worldX;
    sprite.y = worldY;
    sprite.anchor.set(0.5);
    world.addChild(sprite); 
    sprite.scale.set(1/960);
    displayObjs.push(sprite);
    
    updateScale();
  }
  
  function addDebug() {
    // Use this as a ground for world
    const graphics: Graphics = new Graphics();
    graphics.beginFill(0xFF0000);
    graphics.drawRect(-10, -10, 20, 20);
    graphics.endFill();
    graphics.pivot.set(2, 1);
    world.addChild(graphics);
  }
  
  function updateScale() {
    // Scale the viewport & world mask so that it always has 4 world units of width & 2 of height.
    if(window.innerWidth/4 < window.innerHeight/2){
      // Width is the limiting factor to fit 4x2 square. 
      const worldScale = window.innerWidth/4;
      viewportMask.height = window.innerWidth/4*2;
      viewportMask.width = window.innerWidth;
      world.scale.set(worldScale);
    }
    else{
      // Use height as limiting
      const worldScale = window.innerHeight/2;
      viewportMask.height = window.innerHeight;
      viewportMask.width = window.innerHeight/2*4;
      world.scale.set(worldScale);
    }
    // Keep the 4x2 area in the center of screen.
    viewportMask.x = (app.renderer.screen.width-viewportMask.width)/2;
    viewportMask.y = (app.renderer.screen.height-viewportMask.height)/2;
    
  }

  return {
    addDebug: addDebug,
    addSprite: addSprite,
    updateScale: updateScale,
  }
})(world);

utilities.updateScale();
utilities.addDebug();
utilities.addSprite(0, 0, "https://cdn.pixabay.com/photo/2021/08/16/22/04/nature-6551466_960_720.jpg");
utilities.addSprite(1, 1, "https://cdn.pixabay.com/photo/2021/08/16/22/04/nature-6551466_960_720.jpg");
utilities.addSprite(2, 0, "https://cdn.pixabay.com/photo/2021/08/16/22/04/nature-6551466_960_720.jpg");
utilities.addSprite(3, -1, "https://cdn.pixabay.com/photo/2021/08/16/22/04/nature-6551466_960_720.jpg");
utilities.addSprite(4, 0, "https://cdn.pixabay.com/photo/2021/08/16/22/04/nature-6551466_960_720.jpg");
utilities.addSprite(5, 1, "https://cdn.pixabay.com/photo/2021/08/16/22/04/nature-6551466_960_720.jpg");

utilities.addSprite(-1, 1, "https://cdn.pixabay.com/photo/2021/08/16/22/04/nature-6551466_960_720.jpg");
utilities.addSprite(-2, 0, "https://cdn.pixabay.com/photo/2021/08/16/22/04/nature-6551466_960_720.jpg");
utilities.addSprite(-3, -1, "https://cdn.pixabay.com/photo/2021/08/16/22/04/nature-6551466_960_720.jpg");
utilities.addSprite(-4, 0, "https://cdn.pixabay.com/photo/2021/08/16/22/04/nature-6551466_960_720.jpg");
utilities.addSprite(-5, 1, "https://cdn.pixabay.com/photo/2021/08/16/22/04/nature-6551466_960_720.jpg");

app.stage.addChild(world);
// Update initial scale
utilities.updateScale();
window.onresize = function() {
  resize(app.renderer, world);
  utilities.updateScale();
}

// Add camera movement over time
Ticker.shared.add( ()=>{
  const now = Date.now()/1000;
  camera.x = Math.cos(now*0.1)*2;
  camera.y = Math.sin(now*0.2)*1.5;
  world.pivot.set( camera.x, camera.y );
});

 

Link to comment
Share on other sites

@ExcaThank you for your time. it works, however my main issue is still present. When I add child sprite to the parent sprite, because of the paren't small scale, 1/960 to be precise, it gets applied to the child element, which also has local scale of 1/960, which results child's scale to be 1/921600, and therefore is not visible.

Desired behaviour is that I have something like two types of scales for DisplayO bjects, worldScale and scale. By default although their scale is 1/960, their virtual scale would be 1 and 1. So, only virtual scale would be applied to each other and I would have something like this when I add child sprite with coords x: 0.5 y: 0.5

example2.png.053487aedc99e67f4bae303b393282f6.png

Also, for example if parent has virtual scale of (0.5, 1) then and only then it would get applied to child and they both would look stretched.

Also, because of parent's scale affecting child, to move child with 1 virtual scale to left by one world unit, I have set its x value to be 960. Basically, this scaling won't let me make use of graph scene  hierarchy.

Link to comment
Share on other sites

p. s. I edited the codepen example just a bit. look at line 106

const parent: Sprite = utilities.addSprite(0, 0, "https://cdn.pixabay.com/photo/2021/08/16/22/04/nature-6551466_960_720.jpg");
const child: Sprite = utilities.addSprite(0.5, 0.5, "https://cdn.pixabay.com/photo/2021/08/16/22/04/nature-6551466_960_720.jpg");
parent.addChild(child); // comment this to see the pic

 

Link to comment
Share on other sites

Ah true. That solution will have issues if you want to have a scene graph inside the world.

One way to fix that could be to use empty containers and have sprites always as leaf nodes. Though that adds a bit of unnecessary complexity to scene graph.

Cant immediatly come up with a solution to that. Need to test few things.

Link to comment
Share on other sites

How so? If sprites would always be leaf nodes, then again one sprite cannot have a child node. You mean I should have something like this? With sprites just being texture holders and all the "virtual" transformations would be done through containers.

// make parent
const parentContainer: Container = new Container();
const parentSprite: Sprite = new Sprite();
parentContainer.addChild(parentSprite);

// apply all transformations to parent container
parentContainer.scale.set(0.5, 0.5);

// make child
const childContainer: Container new Container();
const childCSprite: Sprite = new Sprite();
childContainer.addChild(childSprite);

// add child and apply tranform to child
parentContainer.addChild(childContainer);
childContainer.position.set(1, 1);

Yeah, I have been breaking my head into this for a long time now, and still couldn't find a good solution(( pixi.js's scene graph is very coupled with pixels ( if I can formulate that way ).

Link to comment
Share on other sites

Having it like this (example assumes somekind of character):
world
 - Character 1 (container)
  - Torso (sprite, scaled)
  - Belt (sprite, scaled)
  - Head (container)
     - Face ( sprite, scaled)
     - Hat (sprite,scaled)
     - Eyes (container)
        - Eye 1 (sprite, scaled)
        - Eye 2 (sprite, scaled)
     - Mouth (sprite,scaled)
  - Legs (container)
     - Leg 1 (sprite, scaled)
     - Leg 2 (sprite, scaled)
 

Link to comment
Share on other sites

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.
Note: Your post will require moderator approval before it will be visible.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

Loading...
 Share

  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...