💾 Archived View for gemini.spicyporkbun.online › text › petzgraphics.gmi captured on 2022-04-29 at 11:26:48. Gemini links have been rewritten to link to archived content

View Raw

More Information

⬅️ Previous capture (2021-11-30)

-=-=-=-=-=-=-

Petz Rendering in OpenGL

Why 3D rather than real 2D like the original game?

Because I'm lazy and I want the game engine to handle placing and rotating 3D objects for me!

Rendering the ballz

Rendering a solid-color circle is easy-peasy. Create a billboarded quad and draw a circle on it.

But we want something more complex. We want a _pixel-perfect_ circle. If Petz says the ball size is 11, then our circle must be _exactly_ 11 pixels wide (assuming the default pet/ball scale).

Imagine a quad that fills the entire viewport. It's easy to draw a circle anywhere in screen-space on this quad using FRAGCOORD.xy, and since FRAGCOORD gives you the exact screen pixel, you can make the circle an exact pixel width too. Here's how to draw an 11-pixel circle centered around pixel (300,300).

void fragment() {
  vec2 center_pixel = vec2(300.5);
  ALBEDO = vec3(step(length(FRAGCOORD.xy - center_pixel), 5.5));
}

We have to center the circle around a half-point and extend it to a half-point, i.e. have an odd-numbered circle, for it to actually look like a circle and not like a weird square at certain small sizes. Petz always ensures that the balls are odd widths, so let's stick with that.

So the only thing we need to do in order to render a given ball, is to find out where its center is on-screen. This is easily done, as long as we know exactly what the viewport size is.

varying flat vec2 center_fragcoord;
uniform int ball_radius;

void vertex() {
  // billboard
  MODELVIEW_MATRIX = INV_CAMERA_MATRIX * mat4(CAMERA_MATRIX[0],CAMERA_MATRIX[1],CAMERA_MATRIX[2],WORLD_MATRIX[3]);

  // Calculate center fragcoord. Do in vertex shader to avoid calculating per frag.
  vec4 center_clip_space = MODELVIEW_MATRIX * vec4(vec3(0.0), 1.0);
  vec4 center_view_space = PROJECTION_MATRIX * center_clip_space;
  vec2 center_ndc = (center_view_space.xy + 1.0) / 2.0;
  center_fragcoord = floor(center_ndc * VIEWPORT_SIZE) + 0.5;
}

void fragment() {
  ALBEDO = vec3(0.0);
  ALPHA = vec3(step(length(FRAGCOORD.xy - center_fragcoord), float(ball_radius) + 0.5));
  ALPHA_SCISSOR = 1.0;
}

And, to stop tons of overdraw, move the vertices of the quad inward so that they match the ball_radius.

The pixel-perfect drawing isn't implemented properly currently.

Fuzz

Ball fuzz (hehe) is easy to draw because the ball is always really a square. Fuzzing is just moving the lines of the ball left and right a bit on-screen.

I use the random number generation method suggested by Book of Shaders. You can tell Petz is using something similar because of the specific pattern of fuzzing and the fact that multiple balls can visibly have the same fuzz pattern, so it's clearly deterministic.

Simply find a random number per FRAGCOORD.y + center_fragcoord.y and offset FRAGCOORD.x by that random number. The reason you offset by center_fragcoord.y is so that the fuzz doesn't appear to shift and change as the ball moves around the screen - it seems to have a persistent amount of fuzz. Make sure the quad is expanded enough to contain the maximum possible fuzz.

Outlines

This is reasonably simple, only complicated by the fact that Petz has so many outline variations.

Outlines always cut into the core ball. That is, if a ball has size 11 with a real outline width (see below) of 2, the ball is still 11 pixels wide, but the base color only takes up 9 of those pixels.

Outline size does not scale with the width of the ball. It's a set x pixels.

Outline -1 is no outline.

Outlines -2 and 0 give outlines on one side of the ball only (i.e. the outline looks like the ball shifted left/right by 1 pixel).

Outline -3 draws as a nose, i.e. with a white rectangle of shine.

Outline -4 gives a weird glitchy effect.

Outline -5 or below seems to give a normal 1px outline.

Outline of 1 gives a 'dotted' outline, that is, like outline -2 and 0 together. The left and right side of the ball is outlined by 1px, but not the top/bottom.

Outlines of 2+ give a full outline of (x-1) width.

To render most of these it's just a matter of shifting around FRAGCOORD to get the desired effect.

Rendering linez

Lines are a lot harder to deal with. I wrote about them in a previous '3D in 2D' article. Here we must also make the lines a pixel-perfect width, which can be done when forcing the vertices outwards along the line normal.

Z-sorting

Lines must always render behind their balls. Applying a z-penalty can kinda get you there, but there's always the possibility it will render too far back and get hidden, or too far forward and clip a little bit.

To try and prevent this, I pass through the two world positions of the linked balls. I find their screen-Z coordinates, take the maximum, apply a tiny z-penalty, and use that as the z for every line vertex. The base game won't have animations where lines need to cross z-coordinates so this is fine.

Fuzz and outlines

Fuzz/outlines are hard to draw on lines and I think the solution I came up with is pretty stupid, but it mostly works.

Fuzz, as on balls, is the horizontal shifting of each vertical line. This can be done in the same way as for balls - find a random number per FRAGCOORD.y + center_fragcoord.y and shift FRAGCOORD.x by it.

However, to determine where the outline should go, we need to know the outer x edges of the line. And, unless the line is completely vertical, those boundaries change per y-line. Even on a line with no fuzz, the left outline at the top of the line is probably at a completely different x than the left outline at the bottom of the line.

This is where the dumb stuff comes in. In the vertex shader, I calculate the left and right boundaries at each vertex using a simple equation for straight lines, and I let the vertex shader interpolate those values. This fails on absolutely horizontal/vertical lines but works well enough.

Outlines for lines are much simpler. They are always one pixel. Since we know the boundaries, we just need to cut in by one pixel.

Rendering paintballs

Paintballs act as if masked by their parent ball. They have a true 3D position (given by the normalized vector + the base ball's radius) but obviously their display should not 'leak' outside of the parent ball.

To get this effect, the paintball needs to know the base ball's world position and radius. The world position is projected into a screen-space position, I calculate whether the current FRAGCOORD is within the base ball's radius, and modify the ALPHA accordingly. Everything else about their shader is the same as a regular ball.

Irises are rendered as paintballs since they're masked by the eye balls.

Rendering textures

This one is kind of fun (and brute-forced!). Petz textures are indexed, but Godot/OpenGL doesn't know anything about that. It sees them as full-color images. But Petz uses those indexes heavily to modify textures according to the ball color. The palette is arranged in tens: ten reds in decreasing brightness, ten oranges, ten blues, etc. You can take a texture which uses the ten red colors, and 'palette shift' them to using the ten blue colors instead if the base ball is blue. This preserves shading but switches the color. Or you can take a red texture with orange stripes, shift the reds to blue, and have a blue ball with orange stripes.

I went with a lazy solution to this. I generated a lookup table: a 1x256px image with each pixel set to the corresponding palette color. At each pixel, I scan through this image until I find a color identical to the one from the ball's texture. Now I know the color index. If this index is to be shifted, I calculate the new index, and look up the real color in the LUT.

The new index is just same 'shade' but in the new color range. e.g. if the texture color is 65, and the ball's color is 80, then the new color index is 85.

Move, project, addballs and animation

Animations

There's already an explanation of animation files in Nick Sherlock's repo, so I won't go over that here except to say that the first 3 bytes of his 'tags' are rotational data (x,y,z). The last byte I'm not sure about. I expect there to be eye angle data somewhere in the animation, maybe blend info, and maybe ball size diffs.

Addball positions

It's important to note that the x,y,z positional data of the addball is relative to the base ball's position _and rotation_. That is, if the addball is positioned 'above' the base ball but the base ball is rotated to face downwards, the addball will show below the base ball.

Move

The positional data here is probably relative to rotation too but I haven't implemented that yet. It doesn't seem to screw things up too badly.

Project

It's important to note that this section is processed in order.

This is why you can sometimes see 'useless' rows in here like `43 44 100`. This seems to say that tail2 is 100% of the distance from tail1. Why would this be necessary, surely it is 100% of the distance anyway? Well, it isn't if, say, the butt has moved up and now tail1 would be closer to tail2 without adjustment. This resets the distance between tail2 and tail1 to be whatever it was in the original animation by moving tail2 further/closer.

You can also see the effect of this if you modify the distance between tail1 and tail2, but do not define project lines for tail3+, or define them before tail2. The subsequent tail balls will not be projected from the moved tail2 properly.