From 2542cb591db2fe4f619cb2ac6b83f813a564f55e Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Thu, 10 Sep 2026 16:15:12 +0300 Subject: [PATCH] feat(render3d): a rim outline around a highlighted actor An actor gains `outline` (metres of rim) and `ocol` (its colour). The pass draws the model a second time with its vertices pushed out along their normals and its front faces culled, so only the far side of the swollen shell survives - a silhouette exactly `outline` metres wide. It is depth-tested against the scene, so anything standing in front of the actor hides the rim too. skin.vert grows an OUTLINE path for the push; outline.frag is the flat-colour fragment shader it pairs with. Co-Authored-By: Claude Opus 5 --- packages/ludic.render3d/actor.ludic | 49 +++++++++++++++++++- packages/ludic.render3d/shaders/outline.frag | 14 ++++++ packages/ludic.render3d/shaders/skin.vert | 8 ++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 packages/ludic.render3d/shaders/outline.frag diff --git a/packages/ludic.render3d/actor.ludic b/packages/ludic.render3d/actor.ludic index f0c25329..3633f859 100644 --- a/packages/ludic.render3d/actor.ludic +++ b/packages/ludic.render3d/actor.ludic @@ -30,7 +30,9 @@ property Actor { ptint: words, # 4 per primitive: on flag, r, g, b (a part's own colour) hide: words, # 1 per primitive: skip it (a cap taken off) radius: int = 0, # float bits: bounding radius for culling (0 = never culled) - cull: int = 0 # float bits: not drawn beyond this distance (0 = always) + cull: int = 0, # float bits: not drawn beyond this distance (0 = always) + outline: int = 0, # float bits: metres of rim drawn around it (0 = none) + ocol: words # the rim's colour (null = white) } # a program and its uniform locations property AcProg { @@ -44,6 +46,8 @@ property AcProg { l_emis: int = -1, l_view: int = -1, l_proj: int = -1, + l_out: int = -1, + l_ocol: int = -1, l_mh: int = -1, scene_frame: int = -1 # the frame its scene uniforms were bound } @@ -52,6 +56,8 @@ var ac_lit: AcProg = null var ac_lit_cut: AcProg = null var ac_sh: AcProg = null var ac_sh_cut: AcProg = null +var ac_out: AcProg = null +var ac_out_cut: AcProg = null var ac_actors: []Actor = null var ac_next_id: int = 0 var ac_frame: int = 0 @@ -64,6 +70,7 @@ function ac_prog_new(vs: string, fs: string, defs: string) -> AcProg { a.l_bones = gl_uniform(p, "u_bones[0]"); if a.l_bones < 0 { a.l_bones = gl_uniform(p, "u_bones") } a.l_tint = gl_uniform(p, "u_tint"); a.l_rough = gl_uniform(p, "u_rough_scale"); a.l_emis = gl_uniform(p, "u_emissive") a.l_view = gl_uniform(p, "u_view"); a.l_proj = gl_uniform(p, "u_proj"); a.l_mh = gl_uniform(p, "u_model_h") + a.l_out = gl_uniform(p, "u_outline"); a.l_ocol = gl_uniform(p, "u_outline_col") # the samplers never move: units 0, 1, 2 gl_use_program(p) gl_uniform1i(gl_uniform(p, "u_diff"), 0); gl_uniform1i(gl_uniform(p, "u_nrm"), 1); gl_uniform1i(gl_uniform(p, "u_arm"), 2) @@ -74,6 +81,8 @@ function actor_init() -> void { ac_lit_cut = ac_prog_new("skin.vert", "model.frag", "#define ALPHA_TEST\n") ac_sh = ac_prog_new("skin.vert", "shadow.frag", "#define SHADOW_PASS\n") ac_sh_cut = ac_prog_new("skin.vert", "shadow.frag", "#define SHADOW_PASS\n#define ALPHA_TEST\n") + ac_out = ac_prog_new("skin.vert", "outline.frag", "#define OUTLINE\n") + ac_out_cut = ac_prog_new("skin.vert", "outline.frag", "#define OUTLINE\n#define ALPHA_TEST\n") ac_actors = new []Actor } function actor_remove(a: Actor) -> void { @@ -188,8 +197,46 @@ function actor_draw() -> void { ac_bind_scene(ap) actor_draw_one(a, ap, false) } + actor_draw_outlines() gl_enable(GL_CULL_FACE) } +# The rim around a highlighted actor: the model again, its vertices pushed out along their +# normals (skin.vert, OUTLINE) and its front faces culled, so only the far side of the +# swollen shell survives — a silhouette exactly `outline` metres wide. Depth-tested +# against the scene, so anything in front of the actor hides its rim too. +function actor_draw_outlines() -> void { + var any = false + for i in 0 .. len(ac_actors) { if ac_actors[i].outline != 0 and ac_visible(ac_actors[i], false) { any = true; break } } + if not any { return } + gl_enable(GL_CULL_FACE) + gl_cull_face(GL_FRONT) + for i in 0 .. len(ac_actors) { + let a = ac_actors[i] + if a.outline == 0 or not ac_visible(a, false) { continue } + var ap = ac_out + if a.cutout { ap = ac_out_cut } + gl_use_program(ap.prog) + u_mat4(ap.l_view, cam_view); u_mat4(ap.l_proj, cam_proj) + u_f(ap.l_out, f_mul(a.outline, a.scale)) + if a.ocol != null { u_v3(ap.l_ocol, a.ocol) } else { u_f3(ap.l_ocol, F_ONE, F_ONE, F_ONE) } + actor_draw_outline_one(a, ap) + } + gl_cull_face(GL_BACK) +} +function actor_draw_outline_one(a: Actor, ap: AcProg) -> void { + u_mat4(ap.l_model, a.mat) + var skinned = F_ZERO + if a.skin != null { skinned = F_ONE; gl_uniform_matrix4fv(ap.l_bones, a.skin.n_joints, 0, a.skin.bones) } + else if a.model.skin != null { skinned = F_ONE; gl_uniform_matrix4fv(ap.l_bones, a.model.skin.n_joints, 0, a.model.skin.bones) } + u_f(ap.l_skin, skinned) + let model = a.model + for i in 0 .. len(model.prims) { + let pr = model.prims[i] + if a.hide != null and a.hide[i] != 0 { continue } + if a.cutout { gl_active_texture(GL_TEXTURE0); gl_bind_texture(GL_TEXTURE_2D, pr.diff) } + mesh_draw(pr.mesh) + } +} function actor_draw_casters(light_vp: words) -> void { if ac_actors == null { return } gl_use_program(ac_sh.prog); u_mat4(ac_sh.l_lvp, light_vp) diff --git a/packages/ludic.render3d/shaders/outline.frag b/packages/ludic.render3d/shaders/outline.frag new file mode 100644 index 00000000..1905d283 --- /dev/null +++ b/packages/ludic.render3d/shaders/outline.frag @@ -0,0 +1,14 @@ +// the inverted-hull shell (skin.vert with OUTLINE): a flat colour, no shading. Cut-out +// meshes still need their shape, so the alpha test comes along. +uniform vec3 u_outline_col; +out vec4 o_color; +#ifdef ALPHA_TEST +in vec2 v_uv; +uniform sampler2D u_diff; +void main() { + if (texture(u_diff, v_uv).a < 0.45) discard; + o_color = vec4(u_outline_col, 1.0); +} +#else +void main() { o_color = vec4(u_outline_col, 1.0); } +#endif diff --git a/packages/ludic.render3d/shaders/skin.vert b/packages/ludic.render3d/shaders/skin.vert index 59b9faa5..60172813 100644 --- a/packages/ludic.render3d/shaders/skin.vert +++ b/packages/ludic.render3d/shaders/skin.vert @@ -12,6 +12,9 @@ uniform float u_skinned; // 0: a rigid model on the same path uniform mat4 u_view; uniform mat4 u_proj; uniform mat4 u_light_vp; +#ifdef OUTLINE +uniform float u_outline; // metres the shell is pushed out +#endif out vec3 v_wpos; out vec3 v_nrm; out vec2 v_uv; @@ -28,6 +31,11 @@ void main() { m = u_model * sk; } vec4 w = m * vec4(a_pos, 1.0); +#ifdef OUTLINE + // an inverted hull: the shell is pushed out along the world normal and drawn with the + // front faces culled, so what survives is a rim of `u_outline` metres around the model + w.xyz += normalize(mat3(m) * a_nrm) * u_outline; +#endif v_wpos = w.xyz; v_nrm = normalize(mat3(m) * a_nrm); v_uv = a_uv;