diff --git a/python/MeshDemo.ipynb b/python/MeshDemo.ipynb index 44e9ea0..59087b4 100644 --- a/python/MeshDemo.ipynb +++ b/python/MeshDemo.ipynb @@ -52,6 +52,91 @@ "mrender.image().resize((width//2, height//2))" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Screen-Space Ambient Occlusion (SSAO) Demo" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Enable SSAO to add realistic ambient occlusion shadows" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a new renderer with SSAO enabled\n", + "mrender_ssao = ogl.MeshRenderer(width, height)\n", + "mrender_ssao.setMesh(P, None, N, C)\n", + "\n", + "mrender_ssao.lookAt(*camParams)\n", + "mrender_ssao.modelMatrix(*modParams)\n", + "mrender_ssao.perspective(50, width / height, 0.1, 2000)\n", + "\n", + "mrender_ssao.meshes[0].alpha = 0.5\n", + "mrender_ssao.meshes[0].lineWidth = 1.5\n", + "mrender_ssao.meshes[0].shininess = 100.0\n", + "mrender_ssao.specularIntensity[:] = 2.0\n", + "\n", + "# Enable SSAO with custom parameters\n", + "mrender_ssao.enableSSAO(enabled=True, radius=0.5, bias=0.025, samples=16)\n", + "\n", + "# Render and display\n", + "mrender_ssao.render()\n", + "mrender_ssao.image().resize((width//2, height//2))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Infinite Ground Plane Demo" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Add an infinite ground plane at z=0 that only displays ambient occlusion" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a new renderer with ground plane\n", + "mrender_ground = ogl.MeshRenderer(width, height)\n", + "mrender_ground.setMesh(P, None, N, C)\n", + "\n", + "mrender_ground.lookAt(*camParams)\n", + "mrender_ground.modelMatrix(*modParams)\n", + "mrender_ground.perspective(50, width / height, 0.1, 2000)\n", + "\n", + "mrender_ground.meshes[0].alpha = 0.5\n", + "mrender_ground.meshes[0].lineWidth = 1.5\n", + "mrender_ground.meshes[0].shininess = 100.0\n", + "mrender_ground.specularIntensity[:] = 2.0\n", + "\n", + "# Enable SSAO\n", + "mrender_ground.enableSSAO(enabled=True, radius=0.5, bias=0.025, samples=16)\n", + "\n", + "# Add an infinite ground plane at z=0 that only shows AO\n", + "mrender_ground.addInfiniteGroundPlane(z=0.0, size=1000.0, only_show_ao=True)\n", + "\n", + "# Render and display\n", + "mrender_ground.render()\n", + "mrender_ground.image().resize((width//2, height//2))" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/python/OffscreenRenderer.py b/python/OffscreenRenderer.py index c248289..3ae5fd7 100644 --- a/python/OffscreenRenderer.py +++ b/python/OffscreenRenderer.py @@ -89,10 +89,16 @@ def lookAtMatrix(position, target, up): return matView class Mesh: - def __init__(self, ctx, V, F, N, color): + def __init__(self, ctx, V, F, N, color, use_ssao=False): self.ctx = ctx - self.shader = ctx.shaderLibrary().load(SHADER_DIR + '/phong_with_wireframe.vert', - SHADER_DIR + '/phong_with_wireframe.frag') + self.use_ssao = use_ssao + # Load SSAO shader if requested, otherwise use regular shader + if use_ssao: + self.shader = ctx.shaderLibrary().load(SHADER_DIR + '/phong_with_wireframe.vert', + SHADER_DIR + '/phong_with_wireframe_ssao.frag') + else: + self.shader = ctx.shaderLibrary().load(SHADER_DIR + '/phong_with_wireframe.vert', + SHADER_DIR + '/phong_with_wireframe.frag') # The triangle index array in active use to replicate vertex data to # per-corner data. @@ -105,6 +111,12 @@ def __init__(self, ctx, V, F, N, color): self.setWireframe(0.0) self.matModel = np.identity(4) self.shininess = 20.0 + + # SSAO parameters (use floats for shader compatibility) + self.ssaoEnabled = 1.0 if use_ssao else 0.0 + self.ssaoRadius = 0.5 + self.ssaoBias = 0.025 + self.ssaoSamples = 16.0 self.vao = None @@ -283,6 +295,13 @@ def render(self, matView): self.shader.setUniform('alpha', self.alpha) self.shader.setUniform('lineWidth', self.lineWidth) + + # Set SSAO uniforms only if using SSAO shader + if self.use_ssao: + self.shader.setUniform('ssaoEnabled', self.ssaoEnabled) + self.shader.setUniform('ssaoRadius', self.ssaoRadius) + self.shader.setUniform('ssaoBias', self.ssaoBias) + self.shader.setUniform('ssaoSamples', self.ssaoSamples) # Any constant color configured is not part of the VAO state and must be set again to ensure it hasn't been overwritten if self.constColor: self.vao.setConstantAttribute(2, self.color) @@ -332,6 +351,15 @@ def __init__(self, width, height): self.specularIntensity = 1.0 * white self.transparentBackground = True + + # SSAO settings (use floats for shader compatibility) + self.ssaoEnabled = 0.0 # Default: disabled + self.ssaoRadius = 0.5 + self.ssaoBias = 0.025 + self.ssaoSamples = 16.0 + + # Ground plane settings + self.infiniteGroundPlane = None def resize(self, width, height): self.ctx.resize(width, height) @@ -342,15 +370,20 @@ def setMesh(self, V, F, N, color, which = 0): `F` can be `None` to disable indexed face set representation (i.e., to use glDrawArrays instead of glDrawElements) """ - if len(self.meshes) == 0: self.meshes = [Mesh(self.ctx, V, F, N, color)] - else: self.meshes[which].setMesh(V, F, N, color) + if len(self.meshes) == 0: + self.meshes = [Mesh(self.ctx, V, F, N, color, use_ssao=(self.ssaoEnabled > 0.5))] + else: + self.meshes[which].setMesh(V, F, N, color) - def addMesh(self, V, F, N, color, makeDefault = True): + def addMesh(self, V, F, N, color, makeDefault = True, use_ssao=None): """ Add a mesh to the scene. Arguments are the same as `setMesh`. By default, the new mesh becomes the active default one (index 0). + If use_ssao is None, uses the renderer's ssaoEnabled setting. """ - self.meshes.insert(0 if makeDefault else len(self.meshes), Mesh(self.ctx, V, F, N, color)) + if use_ssao is None: + use_ssao = (self.ssaoEnabled > 0.5) + self.meshes.insert(0 if makeDefault else len(self.meshes), Mesh(self.ctx, V, F, N, color, use_ssao=use_ssao)) def addVectorFieldMesh(self, V, F, N, arrowPos, arrowVec, arrowColor, arrowRelativeScreenSize, arrowAlignment, targetDepth): @@ -433,6 +466,13 @@ def render(self, clear=True, clearColor = None): self.ctx.blendFunc(GLenum.GL_SRC_ALPHA, GLenum.GL_ONE_MINUS_SRC_ALPHA, GLenum.GL_ONE, GLenum.GL_ONE_MINUS_SRC_ALPHA) + # Sync SSAO settings to all meshes that support it + for mesh in self.meshes: + if hasattr(mesh, 'ssaoEnabled'): + mesh.ssaoEnabled = self.ssaoEnabled + mesh.ssaoRadius = self.ssaoRadius + mesh.ssaoBias = self.ssaoBias + mesh.ssaoSamples = self.ssaoSamples # Render the opaque meshes first # This will result in a perfectly rendered scene with N opaque objects and 1 transparent object. @@ -460,6 +500,78 @@ def scaledImage(self, scaleFactor): img = self.image() return img.resize((int(img.width * scaleFactor), int(img.height * scaleFactor))) + + def enableSSAO(self, enabled=True, radius=0.5, bias=0.025, samples=16): + """ + Enable or disable Screen-Space Ambient Occlusion (SSAO). + + Parameters: + - enabled: Whether to enable SSAO + - radius: The radius of the SSAO sampling sphere + - bias: Bias to prevent self-shadowing artifacts + - samples: Number of samples to take (limited to 16 in shader) + """ + self.ssaoEnabled = 1.0 if enabled else 0.0 + self.ssaoRadius = float(radius) + self.ssaoBias = float(bias) + self.ssaoSamples = float(min(samples, 16)) + + # Update SSAO settings for existing meshes + for mesh in self.meshes: + if hasattr(mesh, 'ssaoEnabled'): + mesh.ssaoEnabled = 1.0 if enabled else 0.0 + mesh.ssaoRadius = float(radius) + mesh.ssaoBias = float(bias) + mesh.ssaoSamples = float(min(samples, 16)) + + def addInfiniteGroundPlane(self, z=0.0, size=1000.0, color=[0.8, 0.8, 0.8], only_show_ao=False): + """ + Add an infinite ground plane at the specified z-coordinate. + The plane only displays ambient occlusion (no direct lighting). + + Parameters: + - z: Z-coordinate of the ground plane + - size: Size of the ground plane (should be large enough to appear infinite) + - color: Color of the ground plane + - only_show_ao: If True, the plane is invisible except for AO shadows + """ + # Create a large quad at z=0 + half_size = size / 2 + V = np.array([ + [-half_size, -half_size, z], + [ half_size, -half_size, z], + [ half_size, half_size, z], + [-half_size, half_size, z] + ]) + + F = np.array([ + [0, 1, 2], + [0, 2, 3] + ], dtype=np.uint32) + + # Normal pointing up (positive z) + N = np.array([ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1] + ]) + + # If only_show_ao is True, make the plane transparent/invisible in color + if only_show_ao: + plane_color = [0, 0, 0, 0] # Fully transparent + else: + plane_color = color if len(color) == 4 else list(color) + [1.0] + + # Add the ground plane mesh with SSAO enabled + self.addMesh(V, F, N, plane_color, makeDefault=False, use_ssao=True) + self.infiniteGroundPlane = self.meshes[-1] + + # Enable SSAO for the ground plane + self.infiniteGroundPlane.ssaoEnabled = 1.0 # Always enable for ground plane + self.infiniteGroundPlane.ssaoRadius = self.ssaoRadius + self.infiniteGroundPlane.ssaoBias = self.ssaoBias + self.infiniteGroundPlane.ssaoSamples = self.ssaoSamples def renderAnimation(self, outPath, nframes, frameCallback, display=False, *videoWriterArgs, **videoWriterKWargs): """ diff --git a/shaders/phong_with_wireframe_ssao.frag b/shaders/phong_with_wireframe_ssao.frag new file mode 100644 index 0000000..5bd9e82 --- /dev/null +++ b/shaders/phong_with_wireframe_ssao.frag @@ -0,0 +1,100 @@ +// Phong shader with wireframe and SSAO support +// Based on phong_with_wireframe.frag with added SSAO +#version 140 + +// Light and material parameters +uniform vec3 lightEyePos; +uniform vec3 diffuseIntensity; +uniform vec3 ambientIntensity; +uniform vec3 specularIntensity; +uniform float shininess; +uniform float alpha; // Transparency + +uniform float lineWidth; + +// SSAO parameters +uniform float ssaoEnabled; // Use float instead of bool for compatibility (0.0 = off, 1.0 = on) +uniform float ssaoRadius; +uniform float ssaoBias; +uniform float ssaoSamples; + +// Fragment shader inputs +in vec3 v2f_eyePos; +in vec3 v2f_eyeNormal; +in vec4 v2f_color; // Used for ambient, specular, and diffuse reflection constants +in vec4 v2f_wireframe_color; + +// For drawing wireframe +noperspective in vec3 v2f_barycentric; // Barycentric coordinate functions. + +// Fragment shader output (pixel color) +out vec4 result; + +// Simple SSAO approximation using screen-space derivatives +float computeSSAO() { + if (ssaoEnabled < 0.5) return 1.0; + + // Use depth-based occlusion approximation + // This is a simplified SSAO that works without additional render passes + vec3 ddxPos = dFdx(v2f_eyePos); + vec3 ddyPos = dFdy(v2f_eyePos); + + // Simple ambient occlusion based on local geometry curvature + float occlusion = 0.0; + int samples = int(min(ssaoSamples, 16.0)); + + const float TWO_PI = 6.283185307; + + for (int i = 0; i < 16; i++) { + if (i >= samples) break; + + float angle = float(i) * (TWO_PI / 16.0); + float cosA = cos(angle); + float sinA = sin(angle); + vec2 offset = vec2(cosA, sinA) * ssaoRadius; + + vec3 samplePos = v2f_eyePos + ddxPos * offset.x + ddyPos * offset.y; + float sampleDepth = length(samplePos); + float currentDepth = length(v2f_eyePos); + + // Simple depth comparison + if (sampleDepth < currentDepth - ssaoBias) { + occlusion += 1.0; + } + } + + return 1.0 - (occlusion / float(samples)); +} + +void main() { + vec3 L = normalize(lightEyePos - v2f_eyePos); + vec3 V = normalize(-v2f_eyePos); + // Use unit normal oriented to always point towards the camera + vec3 N = sign(dot(v2f_eyeNormal, V)) * normalize(v2f_eyeNormal); + vec3 R = reflect(-L, N); + float d = max(dot(L, N), 0.0); + + // Compute SSAO factor + float aoFactor = computeSSAO(); + + // Apply SSAO to ambient lighting + vec3 ambientWithAO = ambientIntensity * aoFactor; + + vec4 color = v2f_color * vec4(ambientWithAO + d * diffuseIntensity, alpha); + if (d != 0.0) color.rgb += pow(max(dot(R, V), 0.0), shininess) * specularIntensity; // Use white specular highlights regardless of material's color + + // Draw a black wireframe if requested + if (lineWidth > 0.0) { + // Clamp now so that the wireframe antialiasing doesn't get blown out. + color = clamp(color, 0.0, 1.0); + + // Trick for implementing "Single-pass Wireframe Rendering" method without a geometry shader: + // infer height above each triangle edge using (norms of) barycentrentric coordinate gradients. + vec3 h = v2f_barycentric / sqrt(pow(dFdx(v2f_barycentric), vec3(2.0)) + pow(dFdy(v2f_barycentric), vec3(2.0))); + float dist = min(min(h.x, h.y), h.z) - 0.5 * lineWidth; // Distance to line border (at lineWidth/2 above h=0) + color = mix(color, v2f_wireframe_color, + exp(-pow(max(dist + 0.9124443057840285280, 0.0), 4))); // dist + log(2)^(1/4) centers transition from 1 to 0 around the edge. + } + + result = color; +} diff --git a/src/python_bindings/CMakeLists.txt b/src/python_bindings/CMakeLists.txt index 7cf4a1a..5b749cc 100644 --- a/src/python_bindings/CMakeLists.txt +++ b/src/python_bindings/CMakeLists.txt @@ -12,7 +12,7 @@ if (NOT TARGET pybind11::module) FetchContent_Declare( pybind11 GIT_REPOSITORY https://github.com/pybind/pybind11.git - GIT_TAG v2.6.1 + GIT_TAG v2.11.1 ) FetchContent_MakeAvailable(pybind11) # add_subdirectory(${pybind11_SOURCE_DIR} pybind11_bin)