A straight forward idea is to transform a point at the top and bottom of the particle into screen space and find the distance. This cancels very nicely and it's pretty simple to work with just the y
coordinate.
The billboard is screen aligned, and view matrices generally don't scale, so the particle size in world space is the same as eye space. That just leaves the projection to get to NDC, the divide by w
and scaling by the viewport size.
A typical projection matrix, P
, might look something like this...
[ +1.2990 +0.0000 +0.0000 +0.0000 ]
[ +0.0000 +1.7321 +0.0000 +0.0000 ]
[ +0.0000 +0.0000 -1.0002 -0.0020 ]
[ +0.0000 +0.0000 -1.0000 +0.0000 ]
Starting with y_eye
, a y coordinate in eye space, the image space coordinate y_image
is obtained in pixels...
Plugging in the radius above/below the billboard and subtracting cancels to...
Or, in text, pixelSize = vpHeight * P[1][1] * radius / w_clip
For a perspective projection, P[1][1] = 1 / tan(fov_y / 2)
. w_clip
is gl_Position.w
, which is also -z_eye
(from the -1
in the perspective matrix). To guarantee your point covers every pixel you want, this may need an additional small constant.
Side note: A sphere on a billboard will look OK in the middle of the screen. If you have a large field of view perspective projection, a true sphere should warp as it approaches the edges of the screen. You could implicitly raycast the virtual sphere for each pixel in the billboard to get a correct result, but the billboard boundary will need to be adjusted accordingly. Quick google results: 1 2 3 4
[EDIT]
Well, since I bothered to test this I'll throw my shaders here too...
Vertex:
#version 150
in vec4 osVert;
uniform mat4 projectionMat;
uniform mat4 modelviewMat;
uniform vec2 vpSize;
flat out vec2 centre;
flat out float radiusPixels;
const float radius = 1.0;
void main()
{
gl_Position = projectionMat * modelviewMat * osVert;
centre = (0.5 * gl_Position.xy/gl_Position.w + 0.5) * vpSize;
gl_PointSize = vpSize.y * projectionMat[1][5] * radius / gl_Position.w;
radiusPixels = gl_PointSize / 2.0;
}
Fragment:
#version 150
flat in vec2 centre;
flat in float radiusPixels;
out vec4 fragColour;
void main()
{
vec2 coord = (gl_FragCoord.xy - centre) / radiusPixels;
float l = length(coord);
if (l > 1.0)
discard;
vec3 pos = vec3(coord, sqrt(1.0-l*l));
fragColour = vec4(vec3(pos.z), 1.0);
}
(Note the visible gap at the bottom right is incorrect as described above)