ifcviewer: edge enhancement post-pass

Adds a per-frame depth-laplacian pass that darkens pixels at sharp
depth discontinuities — silhouettes, overlapping-surface boundaries,
section-cut edges.  Catches the wall-against-wall and slab-against-
ceiling cases that the cavity hint in the lighting shader misses.

Implementation:

- New edge_depth_fbo_ / edge_depth_tex_ — single-sample D24S8 the
  size of the window.  After the main draw, blit the default FB
  depth into it (handles MSAA resolve in the same call).
- Fullscreen triangle generated from gl_VertexID, samples four
  cardinal neighbours, computes |4c - n - s - e - w| on linearized
  depth.  Linearization branches between perspective and ortho via
  u_is_ortho.  Threshold scales with depth so distant edges still
  register.
- Output is multiplicatively blended (GL_DST_COLOR, GL_ZERO) so
  colours just darken; no separate composite step.
- Runs before the pivot/section/axis gizmos so they aren't outlined
  themselves.  HiZ pyramid build still runs after, unchanged.

Per-frame cost is one MSAA depth blit + one fullscreen pass with
five depth samples.  Sub-millisecond at 1080p on a mid GPU.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-04-30 14:34:38 +10:00
parent e7787b6aad
commit db1a2705a3
2 changed files with 145 additions and 0 deletions
+130
View File
@@ -371,6 +371,60 @@ out vec4 frag_color;
void main() { frag_color = v_color; }
)";
// Edge pass — fullscreen triangle generated from gl_VertexID, samples
// resolved depth at four cardinal neighbours, computes a laplacian, and
// outputs a per-pixel darkening factor that gets multiplied into the
// existing colour via GL_DST_COLOR * GL_ZERO blending.
static const char* EDGE_VERTEX_SHADER = R"(
#version 450 core
out vec2 v_uv;
void main() {
vec2 pos = vec2((gl_VertexID & 1) << 2, (gl_VertexID & 2) << 1) - 1.0;
v_uv = pos * 0.5 + 0.5;
gl_Position = vec4(pos, 0.0, 1.0);
}
)";
static const char* EDGE_FRAGMENT_SHADER = R"(
#version 450 core
in vec2 v_uv;
uniform sampler2D u_depth;
uniform vec2 u_texel; // 1.0 / depth-texture size
uniform float u_near;
uniform float u_far;
uniform float u_is_ortho; // 0 or 1
uniform float u_scale; // edge-darkening multiplier
uniform float u_threshold; // laplacian floor (relative to depth)
out vec4 frag_color;
float linearize(float z) {
if (u_is_ortho > 0.5) {
// Ortho: depth buffer is already a linear remap of view-z.
return mix(u_near, u_far, z);
}
// Perspective: standard NDC -> view-z reverse projection.
float ndc = z * 2.0 - 1.0;
return (2.0 * u_near * u_far) / (u_far + u_near - ndc * (u_far - u_near));
}
void main() {
float c = linearize(texture(u_depth, v_uv).r);
float n = linearize(texture(u_depth, v_uv + vec2( 0.0, u_texel.y)).r);
float s = linearize(texture(u_depth, v_uv + vec2( 0.0, -u_texel.y)).r);
float e = linearize(texture(u_depth, v_uv + vec2( u_texel.x, 0.0)).r);
float w = linearize(texture(u_depth, v_uv + vec2(-u_texel.x, 0.0)).r);
// Laplacian magnitude: ~0 on smooth surfaces, large at depth jumps.
// Threshold scales with depth so distant edges still register.
float lap = abs(4.0 * c - n - s - e - w);
float t = u_threshold * c;
float edge = clamp((lap - t) * u_scale, 0.0, 0.6);
// Multiplicative blend (GL_DST_COLOR, GL_ZERO): out = dst * rgb.
frag_color = vec4(vec3(1.0 - edge), 1.0);
}
)";
static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const char* source) {
GLuint shader = gl->glCreateShader(type);
gl->glShaderSource(shader, 1, &source, nullptr);
@@ -573,11 +627,15 @@ ViewportWindow::~ViewportWindow() {
if (pivot_vbo_) gl_->glDeleteBuffers(1, &pivot_vbo_);
if (plane_vao_) gl_->glDeleteVertexArrays(1, &plane_vao_);
if (plane_vbo_) gl_->glDeleteBuffers(1, &plane_vbo_);
if (edge_vao_) gl_->glDeleteVertexArrays(1, &edge_vao_);
if (edge_depth_fbo_) gl_->glDeleteFramebuffers(1, &edge_depth_fbo_);
if (edge_depth_tex_) gl_->glDeleteTextures(1, &edge_depth_tex_);
if (main_program_) gl_->glDeleteProgram(main_program_);
if (pick_program_) gl_->glDeleteProgram(pick_program_);
if (axis_program_) gl_->glDeleteProgram(axis_program_);
if (pivot_program_) gl_->glDeleteProgram(pivot_program_);
if (plane_program_) gl_->glDeleteProgram(plane_program_);
if (edge_program_) gl_->glDeleteProgram(edge_program_);
if (pick_fbo_) gl_->glDeleteFramebuffers(1, &pick_fbo_);
if (pick_color_tex_) gl_->glDeleteTextures(1, &pick_color_tex_);
if (pick_pos_tex_) gl_->glDeleteTextures(1, &pick_pos_tex_);
@@ -725,6 +783,12 @@ void ViewportWindow::buildShaders() {
GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, PLANE_FRAGMENT_SHADER);
plane_program_ = linkProgram(gl_, vs, fs);
}
{
GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, EDGE_VERTEX_SHADER);
GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, EDGE_FRAGMENT_SHADER);
edge_program_ = linkProgram(gl_, vs, fs);
gl_->glCreateVertexArrays(1, &edge_vao_);
}
{
GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, HIZ_DOWNSAMPLE_VS);
GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, HIZ_DOWNSAMPLE_FS);
@@ -2593,6 +2657,7 @@ void ViewportWindow::render() {
hiz_reject_count_.load());
gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0);
renderEdgePass();
renderPivotIndicator();
renderSectionPlanes();
renderAxisGizmo();
@@ -3118,6 +3183,71 @@ void ViewportWindow::updateSectionDrag(int x, int y) {
requestUpdate();
}
void ViewportWindow::renderEdgePass() {
if (!edge_program_) return;
const int w = width() * devicePixelRatio();
const int h = height() * devicePixelRatio();
if (w <= 0 || h <= 0) return;
// Lazy resize. D24S8 to match Qt's default FBO format (depth+stencil
// even though we only sample depth) so the blit doesn't fail.
if (edge_w_ != w || edge_h_ != h) {
if (edge_depth_fbo_) gl_->glDeleteFramebuffers(1, &edge_depth_fbo_);
if (edge_depth_tex_) gl_->glDeleteTextures(1, &edge_depth_tex_);
gl_->glCreateTextures(GL_TEXTURE_2D, 1, &edge_depth_tex_);
gl_->glTextureStorage2D(edge_depth_tex_, 1, GL_DEPTH24_STENCIL8, w, h);
gl_->glCreateFramebuffers(1, &edge_depth_fbo_);
gl_->glNamedFramebufferTexture(edge_depth_fbo_, GL_DEPTH_STENCIL_ATTACHMENT,
edge_depth_tex_, 0);
edge_w_ = w; edge_h_ = h;
}
// Step 1: resolve MSAA depth from default FB → single-sample texture.
gl_->glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
gl_->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, edge_depth_fbo_);
gl_->glBlitFramebuffer(0, 0, w, h, 0, 0, w, h,
GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT,
GL_NEAREST);
// Step 2: fullscreen darkening pass into default FB colour.
gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0);
gl_->glViewport(0, 0, w, h);
gl_->glDisable(GL_DEPTH_TEST);
gl_->glDepthMask(GL_FALSE);
gl_->glEnable(GL_BLEND);
gl_->glBlendFunc(GL_DST_COLOR, GL_ZERO);
gl_->glUseProgram(edge_program_);
gl_->glTextureParameteri(edge_depth_tex_, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
gl_->glTextureParameteri(edge_depth_tex_, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
gl_->glTextureParameteri(edge_depth_tex_, GL_TEXTURE_COMPARE_MODE, GL_NONE);
gl_->glBindTextureUnit(0, edge_depth_tex_);
// Match the (near, far) used in updateCamera(). For ortho the near
// plane is at -depth_extent, far at +depth_extent.
const float depth_extent = camera_distance_ * 10.0f;
const float near_z = projection_ortho_ ? -depth_extent : 0.1f;
const float far_z = depth_extent;
gl_->glUniform1i(gl_->glGetUniformLocation(edge_program_, "u_depth"), 0);
gl_->glUniform2f(gl_->glGetUniformLocation(edge_program_, "u_texel"),
1.0f / float(w), 1.0f / float(h));
gl_->glUniform1f(gl_->glGetUniformLocation(edge_program_, "u_near"), near_z);
gl_->glUniform1f(gl_->glGetUniformLocation(edge_program_, "u_far"), far_z);
gl_->glUniform1f(gl_->glGetUniformLocation(edge_program_, "u_is_ortho"),
projection_ortho_ ? 1.0f : 0.0f);
gl_->glUniform1f(gl_->glGetUniformLocation(edge_program_, "u_scale"), 6.0f);
gl_->glUniform1f(gl_->glGetUniformLocation(edge_program_, "u_threshold"), 0.004f);
gl_->glBindVertexArray(edge_vao_);
gl_->glDrawArrays(GL_TRIANGLES, 0, 3);
gl_->glBindVertexArray(0);
gl_->glDisable(GL_BLEND);
gl_->glDepthMask(GL_TRUE);
gl_->glEnable(GL_DEPTH_TEST);
}
void ViewportWindow::renderPivotIndicator() {
if (!pivot_indicator_visible_ || !pivot_program_ || !pivot_vao_) return;
+15
View File
@@ -289,6 +289,11 @@ private:
void renderPivotIndicator();
void renderSectionPlanes();
void buildSectionPlaneGizmo();
// Post-process edge enhancement: resolve MSAA depth into a single-
// sample texture, then run a fullscreen pass that detects sharp
// depth-laplacian peaks and darkens the colour buffer there. Catches
// silhouettes and overlapping-surface boundaries as faint dark lines.
void renderEdgePass();
// Returns the index of the section plane whose arrow gizmo is under
// (x, y), or -1 if none. Screen-space line-segment distance test.
int hitTestSectionGizmo(int x, int y) const;
@@ -548,6 +553,16 @@ private:
int plane_arrow_offset_ = 0;
int plane_arrow_count_ = 0;
// Edge-enhancement pass resources. edge_depth_tex_ is a single-sample
// resolve target the size of the window; we blit the default FB depth
// into it each frame, then sample it from the fullscreen edge shader.
GLuint edge_program_ = 0;
GLuint edge_depth_fbo_ = 0;
GLuint edge_depth_tex_ = 0;
GLuint edge_vao_ = 0; // empty VAO for fullscreen-triangle draw
int edge_w_ = 0;
int edge_h_ = 0;
// FPS smoothing
int frame_count_ = 0;
float accumulated_time_ = 0.0f;