feat: Points (#82)
All checks were successful
/ test (push) Successful in 2m17s

Replaces the former sole Element entity by two, Sphere and Point, both implementing an Element trait. Adds Point display, uses the former Element display for Sphere. Adds a new "canned" configuration, and the ability to add, select, and nudge Point entities.

Co-authored-by: Aaron Fenyes <aaron.fenyes@fareycircles.ooo>
Reviewed-on: #82
Co-authored-by: Vectornaut <vectornaut@nobody@nowhere.net>
Co-committed-by: Vectornaut <vectornaut@nobody@nowhere.net>
This commit is contained in:
Vectornaut 2025-05-01 19:25:13 +00:00 committed by Glen Whitney
parent 360ce12d8b
commit a2478febc1
9 changed files with 815 additions and 330 deletions

234
app-proto/src/spheres.frag Normal file
View file

@ -0,0 +1,234 @@
#version 300 es
precision highp float;
out vec4 outColor;
// --- inversive geometry ---
struct vecInv {
vec3 sp;
vec2 lt;
};
// --- uniforms ---
// assembly
const int SPHERE_MAX = 200;
uniform int sphere_cnt;
uniform vecInv sphere_list[SPHERE_MAX];
uniform vec3 color_list[SPHERE_MAX];
uniform float highlight_list[SPHERE_MAX];
// view
uniform vec2 resolution;
uniform float shortdim;
// controls
uniform float opacity;
uniform int layer_threshold;
uniform bool debug_mode;
// light and camera
const float focal_slope = 0.3;
const vec3 light_dir = normalize(vec3(2., 2., 1.));
const float ixn_threshold = 0.005;
const float INTERIOR_DIMMING = 0.7;
// --- sRGB ---
// map colors from RGB space to sRGB space, as specified in the sRGB standard
// (IEC 61966-2-1:1999)
//
// https://www.color.org/sRGB.pdf
// https://www.color.org/chardata/rgb/srgb.xalter
//
// in RGB space, color value is proportional to light intensity, so linear
// color-vector interpolation corresponds to physical light mixing. in sRGB
// space, the color encoding used by many monitors, we use more of the value
// interval to represent low intensities, and less of the interval to represent
// high intensities. this improves color quantization
float sRGB(float t) {
if (t <= 0.0031308) {
return 12.92*t;
} else {
return 1.055*pow(t, 5./12.) - 0.055;
}
}
vec3 sRGB(vec3 color) {
return vec3(sRGB(color.r), sRGB(color.g), sRGB(color.b));
}
// --- shading ---
struct Fragment {
vec3 pt;
vec3 normal;
vec4 color;
};
Fragment sphere_shading(vecInv v, vec3 pt, vec3 base_color) {
// the expression for normal needs to be checked. it's supposed to give the
// negative gradient of the lorentz product between the impact point vector
// and the sphere vector with respect to the coordinates of the impact
// point. i calculated it in my head and decided that the result looked good
// enough for now
vec3 normal = normalize(-v.sp + 2.*v.lt.s*pt);
float incidence = dot(normal, light_dir);
float illum = mix(0.4, 1.0, max(incidence, 0.0));
return Fragment(pt, normal, vec4(illum * base_color, opacity));
}
float intersection_dist(Fragment a, Fragment b) {
float intersection_sin = length(cross(a.normal, b.normal));
vec3 disp = a.pt - b.pt;
return max(
abs(dot(a.normal, disp)),
abs(dot(b.normal, disp))
) / intersection_sin;
}
// --- ray-casting ---
struct TaggedDepth {
float depth;
float dimming;
int id;
};
// if `a/b` is less than this threshold, we approximate `a*u^2 + b*u + c` by
// the linear function `b*u + c`
const float DEG_THRESHOLD = 1e-9;
// the depths, represented as multiples of `dir`, where the line generated by
// `dir` hits the sphere represented by `v`. if both depths are positive, the
// smaller one is returned in the first component. if only one depth is
// positive, it could be returned in either component
vec2 sphere_cast(vecInv v, vec3 dir) {
float a = -v.lt.s * dot(dir, dir);
float b = dot(v.sp, dir);
float c = -v.lt.t;
float adjust = 4.*a*c/(b*b);
if (adjust < 1.) {
// as long as `b` is non-zero, the linear approximation of
//
// a*u^2 + b*u + c
//
// at `u = 0` will reach zero at a finite depth `u_lin`. the root of the
// quadratic adjacent to `u_lin` is stored in `lin_root`. if both roots
// have the same sign, `lin_root` will be the one closer to `u = 0`
float square_rect_ratio = 1. + sqrt(1. - adjust);
float lin_root = -(2.*c)/b / square_rect_ratio;
if (abs(a) > DEG_THRESHOLD * abs(b)) {
return vec2(lin_root, -b/(2.*a) * square_rect_ratio);
} else {
return vec2(lin_root, -1.);
}
} else {
// the line through `dir` misses the sphere completely
return vec2(-1., -1.);
}
}
void main() {
vec2 scr = (2.*gl_FragCoord.xy - resolution) / shortdim;
vec3 dir = vec3(focal_slope * scr, -1.);
// cast rays through the spheres
const int LAYER_MAX = 12;
TaggedDepth top_hits [LAYER_MAX];
int layer_cnt = 0;
for (int id = 0; id < sphere_cnt; ++id) {
// find out where the ray hits the sphere
vec2 hit_depths = sphere_cast(sphere_list[id], dir);
// insertion-sort the points we hit into the hit list
float dimming = 1.;
for (int side = 0; side < 2; ++side) {
float depth = hit_depths[side];
if (depth > 0.) {
for (int layer = layer_cnt; layer >= 0; --layer) {
if (layer < 1 || top_hits[layer-1].depth <= depth) {
// we're not as close to the screen as the hit before
// the empty slot, so insert here
if (layer < LAYER_MAX) {
top_hits[layer] = TaggedDepth(depth, dimming, id);
}
break;
} else {
// we're closer to the screen than the hit before the
// empty slot, so move that hit into the empty slot
top_hits[layer] = top_hits[layer-1];
}
}
layer_cnt = min(layer_cnt + 1, LAYER_MAX);
dimming = INTERIOR_DIMMING;
}
}
}
/* DEBUG */
// in debug mode, show the layer count instead of the shaded image
if (debug_mode) {
// at the bottom of the screen, show the color scale instead of the
// layer count
if (gl_FragCoord.y < 10.) layer_cnt = int(16. * gl_FragCoord.x / resolution.x);
// convert number to color
ivec3 bits = layer_cnt / ivec3(1, 2, 4);
vec3 color = mod(vec3(bits), 2.);
if (layer_cnt % 16 >= 8) {
color = mix(color, vec3(0.5), 0.5);
}
outColor = vec4(color, 1.);
return;
}
// composite the sphere fragments
vec3 color = vec3(0.);
int layer = layer_cnt - 1;
TaggedDepth hit = top_hits[layer];
Fragment frag_next = sphere_shading(
sphere_list[hit.id],
hit.depth * dir,
hit.dimming * color_list[hit.id]
);
float highlight_next = highlight_list[hit.id];
--layer;
for (; layer >= layer_threshold; --layer) {
// load the current fragment
Fragment frag = frag_next;
float highlight = highlight_next;
// shade the next fragment
hit = top_hits[layer];
frag_next = sphere_shading(
sphere_list[hit.id],
hit.depth * dir,
hit.dimming * color_list[hit.id]
);
highlight_next = highlight_list[hit.id];
// highlight intersections
float ixn_dist = intersection_dist(frag, frag_next);
float max_highlight = max(highlight, highlight_next);
float ixn_highlight = 0.5 * max_highlight * (1. - smoothstep(2./3.*ixn_threshold, 1.5*ixn_threshold, ixn_dist));
frag.color = mix(frag.color, vec4(1.), ixn_highlight);
frag_next.color = mix(frag_next.color, vec4(1.), ixn_highlight);
// highlight cusps
float cusp_cos = abs(dot(dir, frag.normal));
float cusp_threshold = 2.*sqrt(ixn_threshold * sphere_list[hit.id].lt.s);
float cusp_highlight = highlight * (1. - smoothstep(2./3.*cusp_threshold, 1.5*cusp_threshold, cusp_cos));
frag.color = mix(frag.color, vec4(1.), cusp_highlight);
// composite the current fragment
color = mix(color, frag.color.rgb, frag.color.a);
}
color = mix(color, frag_next.color.rgb, frag_next.color.a);
outColor = vec4(sRGB(color), 1.);
}