diff --git a/CHANGELOG.md b/CHANGELOG.md index ff421145ad..e64e5aa980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Added - Add `SolverXPBD.update_contacts()` to populate `contacts.force` with per-contact spatial forces (linear force and torque) derived from XPBD constraint impulses +- Add `shading_style` parameter to `ViewerGL` for selecting ``"classic"`` or ``"studio"`` rendering styles at construction or runtime +- Add studio shading style with 3-point lighting, shadow mapping, rim highlights, and neutral matte ground plane +- Add edge overlay toggle (`renderer.draw_edges`) for wireframe visualization on top of solid geometry +- Add registry-based shading system (`ShadingStyleConfig` / `STYLE_REGISTRY`) for extensible style registration - Add repeatable `--warp-config KEY=VALUE` CLI option for overriding `warp.config` attributes when running examples - Add 3D texture-based SDF, replacing NanoVDB volumes in the mesh-mesh collision pipeline for improved performance and CPU compatibility. - Parse URDF joint `limit effort="..."` values and propagate them to imported revolute and prismatic joint `effort_limit` settings diff --git a/newton/_src/viewer/gl/opengl.py b/newton/_src/viewer/gl/opengl.py index a23ca97783..376897867f 100644 --- a/newton/_src/viewer/gl/opengl.py +++ b/newton/_src/viewer/gl/opengl.py @@ -5,6 +5,7 @@ import io import os import sys +from dataclasses import dataclass, field import numpy as np import warp as wp @@ -16,12 +17,99 @@ from .shaders import ( FrameShader, ShaderArrow, + ShaderEdge, + ShaderGradientBg, ShaderLine, ShaderShape, + ShaderShapeStudio, ShaderSky, ShadowShader, ) +# --------------------------------------------------------------------------- +# Shading style registry +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ShadingStyleConfig: + """Immutable descriptor for a named shading style. + + Args: + name: Style identifier used in the registry. + shader_class: ShaderShape subclass to instantiate for this style. + draw_sky: Whether to render the sky sphere background. + sun_directions: Per-up-axis key-light direction (un-normalized). + Keys: 0=X-up, 1=Y-up, 2=Z-up. + sky_upper: Override for the sky dome upper (zenith) color, or ``None`` + to use the renderer's ``sky_upper`` attribute (default). + sky_lower: Override for the sky dome lower (horizon) color. + draw_sun: Whether the sky dome renders a sun flare (default ``True``). + gradient_top: Top color for a screen-space gradient background, or + ``None`` to skip the gradient pass (default). + gradient_bottom: Bottom color for the gradient background. + overrides: Shader parameter overrides applied on top of live renderer + state. Keys absent here fall back to the renderer's own values, + so a style only needs to declare what it changes. + """ + + name: str + shader_class: type[ShaderShape] + draw_sky: bool + sun_directions: dict + sky_upper: tuple[float, float, float] | None = None + sky_lower: tuple[float, float, float] | None = None + draw_sun: bool = True + gradient_top: tuple[float, float, float] | None = None + gradient_bottom: tuple[float, float, float] | None = None + overrides: dict = field(default_factory=dict) + + +#: Global registry mapping style name -> config. +#: Register new styles here; no other code needs to change. +STYLE_REGISTRY: dict[str, ShadingStyleConfig] = { + "classic": ShadingStyleConfig( + name="classic", + shader_class=ShaderShape, + draw_sky=True, + sun_directions={ + 0: np.array((0.8, 0.2, -0.3), dtype=np.float32), # X-up + 1: np.array((0.2, 0.8, -0.3), dtype=np.float32), # Y-up + 2: np.array((0.2, -0.3, 0.8), dtype=np.float32), # Z-up + }, + # No overrides — classic defers entirely to live renderer state. + ), + "studio": ShadingStyleConfig( + name="studio", + shader_class=ShaderShapeStudio, + draw_sky=True, + # ~45° elevation so the floor gets moderate (not maximum) direct light. + sun_directions={ + 0: np.array([1.0, 0.6, 0.8], dtype=np.float32), # X-up + 1: np.array([0.6, 1.0, 0.8], dtype=np.float32), # Y-up + 2: np.array([0.8, 0.6, 1.0], dtype=np.float32), # Z-up + }, + sky_upper=(0.68, 0.68, 0.72), + sky_lower=(0.32, 0.32, 0.36), + draw_sun=False, + overrides={ + "fog_color": (0.55, 0.55, 0.58), + "sky_color": (0.72, 0.82, 0.98), + "ground_color": (0.30, 0.28, 0.25), + "light_color": (0.92, 0.90, 0.86), + "env_texture": None, + "env_intensity": 0.0, + "spotlight_enabled": False, + }, + ), +} + + +def _normalized_sun(sun_dirs: dict, up_axis: int) -> np.ndarray: + d = sun_dirs.get(up_axis, sun_dirs[2]) + return d / np.linalg.norm(d) + + ENABLE_CUDA_INTEROP = False ENABLE_GL_CHECKS = False @@ -837,7 +925,7 @@ def update_from_transforms( self._update_vbo(self.world_xforms, colors, materials) # helper to update instance transforms from points - def update_from_points(self, points, widths, colors): + def update_from_points(self, points, widths, colors, materials=None): if points is None: active = 0 else: @@ -863,7 +951,7 @@ def update_from_points(self, points, widths, colors): record_tape=False, ) - self._update_vbo(self.world_xforms, colors, None) + self._update_vbo(self.world_xforms, colors, materials) # upload to vbo def _update_vbo(self, xforms, colors, materials): @@ -966,7 +1054,19 @@ def get_fallback_texture(cls): cls._fallback_texture = tex return cls._fallback_texture - def __init__(self, title="Newton", screen_width=1920, screen_height=1080, vsync=True, headless=None, device=None): + def __init__( + self, + title="Newton", + screen_width=1920, + screen_height=1080, + vsync=True, + headless=None, + device=None, + shading_style: str = "classic", + ): + if shading_style not in STYLE_REGISTRY: + raise ValueError(f"Unknown shading_style {shading_style!r}. Available: {list(STYLE_REGISTRY)}") + self._active_style: ShadingStyleConfig = STYLE_REGISTRY[shading_style] self.draw_sky = True self.draw_fps = True self.draw_shadows = True @@ -974,12 +1074,19 @@ def __init__(self, title="Newton", screen_width=1920, screen_height=1080, vsync= self.wireframe_line_width = 1.5 # pixels self.line_width = 1.5 # pixels, for all log_lines batches self.arrow_scale = 1.0 # uniform scale for arrow line width and head size + self.draw_edges = False + self._edge_color = (0.05, 0.05, 0.05, 1.0) # RGBA dark near-black self.background_color = (68.0 / 255.0, 161.0 / 255.0, 255.0 / 255.0) self.sky_upper = self.background_color self.sky_lower = (40.0 / 255.0, 44.0 / 255.0, 55.0 / 255.0) + # Hemisphere ambient colors — decoupled from the visible sky so the + # sky dome can be a saturated blue while the ambient fill stays neutral. + self.ambient_sky = (0.8, 0.8, 0.85) + self.ambient_ground = (0.3, 0.3, 0.35) + # Lighting settings self._shadow_radius = 3.0 self._diffuse_scale = 1.0 @@ -988,13 +1095,6 @@ def __init__(self, title="Newton", screen_width=1920, screen_height=1080, vsync= self._shadow_extents = 10.0 self._exposure = 1.6 - # Hemispherical ambient light colors, interpolated by dot(N, up). - # Decoupled from the sky background so the visible sky can be a - # saturated blue while the ambient fill stays neutral — a stand-in - # for a proper irradiance map that we don't precompute yet. - self.ambient_sky = (0.8, 0.8, 0.85) - self.ambient_ground = (0.3, 0.3, 0.35) - # On Wayland, PyOpenGL defaults to EGL which cannot see the GLX context # that pyglet creates via XWayland. Force GLX so both libraries agree. # Must be set before PyOpenGL is first imported (platform is selected @@ -1103,6 +1203,7 @@ def __init__(self, title="Newton", screen_width=1920, screen_height=1080, vsync= self._shadow_shader = None self._shadow_width = 4096 self._shadow_height = 4096 + self._light_space_matrix = np.eye(4, dtype=np.float32) self._frame_texture = None self._frame_depth_texture = None @@ -1135,11 +1236,16 @@ def __init__(self, title="Newton", screen_width=1920, screen_height=1080, vsync= self._setup_frame_buffer() self._setup_sky_mesh() self._setup_frame_mesh() + self._setup_gradient_bg_mesh() self._shadow_shader = ShadowShader(gl) - self._shape_shader = ShaderShape(gl) + self._style_shaders: dict[str, ShaderShape] = { + name: cfg.shader_class(gl) for name, cfg in STYLE_REGISTRY.items() + } + self._edge_shader = ShaderEdge(gl) self._frame_shader = FrameShader(gl) self._sky_shader = ShaderSky(gl) + self._gradient_bg_shader = ShaderGradientBg(gl) self._wireframe_shader = ShaderLine(gl) self._arrow_shader = ShaderArrow(gl) @@ -1186,6 +1292,16 @@ def exposure(self) -> float: def exposure(self, value: float): self._exposure = max(float(value), 0.0) + @property + def shading_style(self) -> str: + return self._active_style.name + + @shading_style.setter + def shading_style(self, value: str): + if value not in STYLE_REGISTRY: + raise ValueError(f"Unknown shading_style {value!r}. Available: {list(STYLE_REGISTRY)}") + self._active_style = STYLE_REGISTRY[value] + def update(self): self._make_current() @@ -1207,22 +1323,17 @@ def render(self, camera, objects, lines=None, wireframe_shapes=None, arrows=None gl = RendererGL.gl self._make_current() - gl.glClearColor(*self.sky_upper, 1) + style = self._active_style + bg = style.overrides.get("fog_color", self.sky_upper) + gl.glClearColor(*bg, 1) gl.glEnable(gl.GL_DEPTH_TEST) gl.glDepthMask(True) gl.glDepthRange(0.0, 1.0) self.camera = camera - # Lazy-init sun direction based on camera up axis - if self._sun_direction is None: - _sun_dirs = { - 0: np.array((0.8, 0.2, -0.3)), # X-up - 1: np.array((0.2, 0.8, -0.3)), # Y-up - 2: np.array((0.2, -0.3, 0.8)), # Z-up - } - d = _sun_dirs.get(camera.up_axis, _sun_dirs[2]) - self._sun_direction = d / np.linalg.norm(d) + # Set sun direction for this frame from the active style's key-light table. + self._sun_direction = _normalized_sun(style.sun_directions, camera.up_axis) # Store matrices for other methods self._view_matrix = self.camera.get_view_matrix() @@ -1257,7 +1368,7 @@ def render(self, camera, objects, lines=None, wireframe_shapes=None, arrows=None gl.glBindFramebuffer(gl.GL_DRAW_FRAMEBUFFER, target_fbo) gl.glDrawBuffer(gl.GL_COLOR_ATTACHMENT0) - gl.glClearColor(*self.sky_upper, 1) + gl.glClearColor(*bg, 1) gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) gl.glBindVertexArray(0) @@ -1737,6 +1848,39 @@ def _setup_frame_mesh(self): check_gl_error() + def _setup_gradient_bg_mesh(self): + gl = RendererGL.gl + + # fmt: off + verts = np.array([ + -1.0, -1.0, + 1.0, -1.0, + 1.0, 1.0, + -1.0, 1.0, + ], dtype=np.float32) + # fmt: on + indices = np.array([0, 1, 2, 2, 3, 0], dtype=np.uint32) + + self._grad_bg_vao = gl.GLuint() + gl.glGenVertexArrays(1, self._grad_bg_vao) + gl.glBindVertexArray(self._grad_bg_vao) + + vbo = gl.GLuint() + gl.glGenBuffers(1, vbo) + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, vbo) + gl.glBufferData(gl.GL_ARRAY_BUFFER, verts.nbytes, verts.ctypes.data, gl.GL_STATIC_DRAW) + + ebo = gl.GLuint() + gl.glGenBuffers(1, ebo) + gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, ebo) + gl.glBufferData(gl.GL_ELEMENT_ARRAY_BUFFER, indices.nbytes, indices.ctypes.data, gl.GL_STATIC_DRAW) + + gl.glVertexAttribPointer(0, 2, gl.GL_FLOAT, gl.GL_FALSE, 2 * verts.itemsize, ctypes.c_void_p(0)) + gl.glEnableVertexAttribArray(0) + + gl.glBindVertexArray(0) + check_gl_error() + def _setup_shadow_buffer(self): gl = RendererGL.gl @@ -1803,43 +1947,78 @@ def _render_shadow_map(self, objects): check_gl_error() + def _build_shader_kwargs(self) -> dict: + """Merge active style overrides onto live renderer defaults for shader.update(). + + Renderer defaults are built first; style overrides are applied on top via + dict.update(), so a style only needs to declare the keys it changes. + dict.update() is safe for all values including black (0,0,0) and 0.0. + """ + kwargs = { + "view_matrix": self._view_matrix, + "projection_matrix": self._projection_matrix, + "view_pos": self.camera.pos, + "up_axis": self.camera.up_axis, + "sun_direction": tuple(self._sun_direction), + "fog_color": self.sky_lower, + "sky_color": self.ambient_sky, + "ground_color": self.ambient_ground, + "light_color": self._light_color, + "enable_shadows": self.draw_shadows, + "shadow_texture": self._shadow_texture, + "light_space_matrix": self._light_space_matrix, + "env_texture": self._env_texture, + "env_intensity": self._env_intensity, + "shadow_radius": self.shadow_radius, + "shadow_extents": self.shadow_extents, + "diffuse_scale": self.diffuse_scale, + "specular_scale": self.specular_scale, + "spotlight_enabled": self.spotlight_enabled, + "exposure": self.exposure, + } + kwargs.update(self._active_style.overrides) + return kwargs + def _render_scene(self, objects): gl = RendererGL.gl + style = self._active_style - if self.draw_sky: + if style.gradient_top is not None: + self._draw_gradient_bg(style.gradient_top, style.gradient_bottom) + + if self.draw_sky and style.draw_sky: self._draw_sky() if self.draw_wireframe: gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE) - self._shape_shader.update( - view_matrix=self._view_matrix, - projection_matrix=self._projection_matrix, - view_pos=self.camera.pos, - fog_color=self.sky_lower, - up_axis=self.camera.up_axis, - sun_direction=self._sun_direction, - enable_shadows=self.draw_shadows, - shadow_texture=self._shadow_texture, - light_space_matrix=self._light_space_matrix, - light_color=self._light_color, - sky_color=self.ambient_sky, - ground_color=self.ambient_ground, - env_texture=self._env_texture, - env_intensity=self._env_intensity, - shadow_radius=self.shadow_radius, - diffuse_scale=self.diffuse_scale, - specular_scale=self.specular_scale, - spotlight_enabled=self.spotlight_enabled, - shadow_extents=self.shadow_extents, - exposure=self.exposure, - ) - - with self._shape_shader: + shader = self._style_shaders[style.name] + shader.update(**self._build_shader_kwargs()) + with shader: self._draw_objects(objects) gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL) + # Edge overlay pass — draw the same geometry a second time as lines. + # GL_LEQUAL lets edges pass the depth test against their own solid surface + # (same geometry → same interpolated depths) while correctly hiding edges of + # objects that are occluded (their depth > buffer value from the solid pass). + # No polygon offset is needed or wanted: factor-based offsets shift depths on + # steep surfaces enough to let occluded edges bleed through other objects. + if self.draw_edges: + self._edge_shader.update( + view_matrix=self._view_matrix, + projection_matrix=self._projection_matrix, + edge_color=self._edge_color, + light_space_matrix=self._light_space_matrix, + ) + gl.glDepthFunc(gl.GL_LEQUAL) + gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE) + with self._edge_shader: + self._draw_objects(objects) + gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL) + gl.glDepthFunc(gl.GL_LESS) + check_gl_error() def _render_lines(self, lines): @@ -1936,14 +2115,16 @@ def _draw_sky(self): self._make_current() + style = self._active_style + sun_dir = self._sun_direction if style.draw_sun else (0.0, 0.0, 0.0) self._sky_shader.update( view_matrix=self._view_matrix, projection_matrix=self._projection_matrix, camera_pos=self.camera.pos, camera_far=self.camera.far, - sky_upper=self.sky_upper, - sky_lower=self.sky_lower, - sun_direction=self._sun_direction, + sky_upper=style.sky_upper or self.sky_upper, + sky_lower=style.sky_lower or self.sky_lower, + sun_direction=sun_dir, up_axis=self.camera.up_axis, ) @@ -1953,6 +2134,24 @@ def _draw_sky(self): check_gl_error() + def _draw_gradient_bg( + self, + top_color: tuple[float, float, float], + bottom_color: tuple[float, float, float], + ): + gl = RendererGL.gl + self._make_current() + + gl.glDepthMask(gl.GL_FALSE) + self._gradient_bg_shader.update(top_color=top_color, bottom_color=bottom_color) + with self._gradient_bg_shader: + gl.glBindVertexArray(self._grad_bg_vao) + gl.glDrawElements(gl.GL_TRIANGLES, 6, gl.GL_UNSIGNED_INT, None) + gl.glBindVertexArray(0) + gl.glDepthMask(gl.GL_TRUE) + + check_gl_error() + def set_environment_map(self, path: str, intensity: float = 1.0) -> None: gl = RendererGL.gl from ...utils.texture import load_texture_from_file # noqa: PLC0415 diff --git a/newton/_src/viewer/gl/shaders.py b/newton/_src/viewer/gl/shaders.py index 27b831444b..daba438168 100644 --- a/newton/_src/viewer/gl/shaders.py +++ b/newton/_src/viewer/gl/shaders.py @@ -434,6 +434,32 @@ } """ +gradient_bg_vertex_shader = """ +#version 330 core +layout (location = 0) in vec2 aPos; + +out float vT; + +void main() { + gl_Position = vec4(aPos, 0.0, 1.0); + vT = aPos.y * 0.5 + 0.5; // [-1,1] -> [0,1] +} +""" + +gradient_bg_fragment_shader = """ +#version 330 core +out vec4 FragColor; + +in float vT; + +uniform vec3 top_color; +uniform vec3 bottom_color; + +void main() { + FragColor = vec4(mix(bottom_color, top_color, vT), 1.0); +} +""" + frame_vertex_shader = """ #version 330 core layout (location = 0) in vec3 aPos; @@ -503,14 +529,13 @@ def __exit__(self, exc_type, exc_val, exc_tb): class ShaderShape(ShaderGL): """Shader for rendering 3D shapes with lighting and shadows.""" - def __init__(self, gl): + def __init__(self, gl, fragment_shader: str | None = None): super().__init__() from pyglet.graphics.shader import Shader, ShaderProgram self._gl = gl - self.shader_program = ShaderProgram( - Shader(shape_vertex_shader, "vertex"), Shader(shape_fragment_shader, "fragment") - ) + frag = fragment_shader if fragment_shader is not None else shape_fragment_shader + self.shader_program = ShaderProgram(Shader(shape_vertex_shader, "vertex"), Shader(frag, "fragment")) # Get all uniform locations with self: @@ -534,6 +559,7 @@ def __init__(self, gl): self.loc_spotlight_enabled = self._get_uniform_location("spotlight_enabled") self.loc_shadow_extents = self._get_uniform_location("shadow_extents") self.loc_exposure = self._get_uniform_location("exposure") + self.loc_enable_shadows = self._get_uniform_location("enable_shadows") def update( self, @@ -576,6 +602,7 @@ def update( self._gl.glUniform1i(self.loc_spotlight_enabled, int(spotlight_enabled)) self._gl.glUniform1f(self.loc_shadow_extents, shadow_extents) self._gl.glUniform1f(self.loc_exposure, exposure) + self._gl.glUniform1i(self.loc_enable_shadows, int(enable_shadows)) # Fog and rendering options self._gl.glUniform3f(self.loc_fog_color, *fog_color) @@ -650,6 +677,33 @@ def update( self._gl.glUniform1i(self.loc_up_axis, up_axis) +class ShaderGradientBg(ShaderGL): + """Full-screen gradient background (two-color vertical blend).""" + + def __init__(self, gl): + super().__init__() + from pyglet.graphics.shader import Shader, ShaderProgram + + self._gl = gl + self.shader_program = ShaderProgram( + Shader(gradient_bg_vertex_shader, "vertex"), + Shader(gradient_bg_fragment_shader, "fragment"), + ) + + with self: + self.loc_top_color = self._get_uniform_location("top_color") + self.loc_bottom_color = self._get_uniform_location("bottom_color") + + def update( + self, + top_color: tuple[float, float, float], + bottom_color: tuple[float, float, float], + ): + with self: + self._gl.glUniform3f(self.loc_top_color, *top_color) + self._gl.glUniform3f(self.loc_bottom_color, *bottom_color) + + class ShadowShader(ShaderGL): """Shader for rendering shadow maps.""" @@ -696,6 +750,271 @@ def update(self, texture_unit: int = 0): self._gl.glUniform1i(self.loc_texture, texture_unit) +shape_fragment_shader_studio = """ +#version 330 core +out vec4 FragColor; + +in vec3 Normal; +in vec3 FragPos; +in vec3 LocalPos; +in vec2 TexCoord; +in vec3 ObjectColor; +in vec4 FragPosLightSpace; +in vec4 Material; + +uniform vec3 view_pos; +uniform vec3 light_color; +uniform vec3 sky_color; +uniform vec3 ground_color; +uniform vec3 sun_direction; +uniform sampler2D albedo_map; +uniform sampler2D shadow_map; +uniform mat4 light_space_matrix; +uniform float shadow_radius; +uniform float shadow_extents; +uniform float exposure; +uniform float diffuse_scale; +uniform float specular_scale; +uniform int enable_shadows; +uniform int up_axis; + +const float PI = 3.14159265359; + +vec2 poissonDisk[16] = vec2[]( + vec2( -0.94201624, -0.39906216 ), + vec2( 0.94558609, -0.76890725 ), + vec2( -0.094184101, -0.92938870 ), + vec2( 0.34495938, 0.29387760 ), + vec2( -0.91588581, 0.45771432 ), + vec2( -0.81544232, -0.87912464 ), + vec2( -0.38277543, 0.27676845 ), + vec2( 0.97484398, 0.75648379 ), + vec2( 0.44323325, -0.97511554 ), + vec2( 0.53742981, -0.47373420 ), + vec2( -0.26496911, -0.41893023 ), + vec2( 0.79197514, 0.19090188 ), + vec2( -0.24188840, 0.99706507 ), + vec2( -0.81409955, 0.91437590 ), + vec2( 0.19984126, 0.78641367 ), + vec2( 0.14383161, -0.14100790 ) +); + +float rand(vec2 co) { + return fract(sin(dot(co.xy, vec2(12.9898, 78.233))) * 43758.5453); +} + +float ShadowCalculation() +{ + vec3 normal = normalize(Normal); + if (!gl_FrontFacing) normal = -normal; + + float worldTexel = (shadow_extents * 2.0) / float(4096); + float normalBias = 2.0 * worldTexel; + vec4 light_space_pos = light_space_matrix * vec4(FragPos + normal * normalBias, 1.0); + vec3 projCoords = light_space_pos.xyz / light_space_pos.w; + projCoords = projCoords * 0.5 + 0.5; + if (projCoords.z > 1.0) return 0.0; + + float frag_depth = projCoords.z; + float fade = 1.0; + float margin = 0.15; + fade *= smoothstep(0.0, margin, projCoords.x); + fade *= smoothstep(0.0, margin, 1.0 - projCoords.x); + fade *= smoothstep(0.0, margin, projCoords.y); + fade *= smoothstep(0.0, margin, 1.0 - projCoords.y); + + float NdotL_bias = max(dot(normal, normalize(sun_direction)), 0.0); + float depthBias = mix(0.0003, 0.00002, NdotL_bias); + float biased_depth = frag_depth - depthBias; + + float shadow = 0.0; + vec2 texelSize = 1.0 / textureSize(shadow_map, 0); + float angle = rand(gl_FragCoord.xy) * 2.0 * PI; + float s = sin(angle); float c = cos(angle); + mat2 rot = mat2(c, -s, s, c); + for (int i = 0; i < 16; i++) { + vec2 offset = rot * poissonDisk[i]; + float pcf_depth = texture(shadow_map, projCoords.xy + offset * shadow_radius * texelSize).r; + if (pcf_depth < biased_depth) shadow += 1.0; + } + return (shadow / 16.0) * fade; +} + +float filterwidth(vec2 v) +{ + vec2 fw = max(abs(dFdx(v)), abs(dFdy(v))); + return max(fw.x, fw.y); +} + +vec2 bump(vec2 x) +{ + return (floor(x / 2.0) + 2.0 * max(x / 2.0 - floor(x / 2.0) - 0.5, 0.0)); +} + +float checker(vec2 uv) +{ + float width = filterwidth(uv); + vec2 p0 = uv - 0.5 * width; + vec2 p1 = uv + 0.5 * width; + vec2 i = (bump(p1) - bump(p0)) / width; + return i.x * i.y + (1.0 - i.x) * (1.0 - i.y); +} + +void main() +{ + float roughness = clamp(Material.x, 0.0, 1.0); + float metallic = clamp(Material.y, 0.0, 1.0); + float checker_enable = Material.z; + float texture_enable = Material.w; + float checker_scale = 1.0; + + // Ground plane (checker) should look matte in studio lighting + float ground_dampen = 1.0; + if (checker_enable > 0.0) { + roughness = max(roughness, 0.85); + ground_dampen = 0.35; // reduce direct light on ground + } + + // convert to linear space + vec3 albedo = pow(ObjectColor, vec3(2.2)); + if (texture_enable > 0.5) + { + vec3 tex_color = texture(albedo_map, TexCoord).rgb; + albedo *= pow(tex_color, vec3(2.2)); + } + + if (checker_enable > 0.0) + { + vec2 uv = LocalPos.xy * checker_scale; + float cb = checker(uv); + vec3 albedo2 = albedo * 0.7; + albedo = mix(albedo, albedo2, cb); + } + + // Specular color: dielectrics ~0.04, metals use albedo. + vec3 F0 = mix(vec3(0.04), albedo, metallic); + + // Metals: lift albedo toward brighter, less saturated version (no full IBL). + float luma = dot(albedo, vec3(0.2126, 0.7152, 0.0722)); + albedo = mix(albedo, vec3(luma * 1.4), metallic * 0.45); + + vec3 N = normalize(Normal); + if (!gl_FrontFacing) + N = -N; + vec3 V = normalize(view_pos - FragPos); + vec3 L = normalize(sun_direction); + vec3 H = normalize(V + L); + + // Up axis + vec3 up = vec3(0.0, 1.0, 0.0); + if (up_axis == 0) up = vec3(1.0, 0.0, 0.0); + if (up_axis == 2) up = vec3(0.0, 0.0, 1.0); + + // Cook-Torrance PBR + float NdotL = max(dot(N, L), 0.0); + float NdotH = max(dot(N, H), 0.0); + float NdotV = max(dot(N, V), 0.001); + float HdotV = max(dot(H, V), 0.0); + + // GGX/Trowbridge-Reitz normal distribution + float a = roughness * roughness; + float a2 = a * a; + float denom = NdotH * NdotH * (a2 - 1.0) + 1.0; + float D = a2 / (PI * denom * denom); + + // Schlick-GGX geometry (Smith method) + float k = (roughness + 1.0) * (roughness + 1.0) / 8.0; + float G1_V = NdotV / (NdotV * (1.0 - k) + k); + float G1_L = NdotL / (NdotL * (1.0 - k) + k); + float G = G1_V * G1_L; + + // Schlick Fresnel, dampened by roughness + vec3 F_max = mix(F0, vec3(1.0), 1.0 - roughness); + vec3 F = F0 + (F_max - F0) * pow(1.0 - HdotV, 5.0); + + // Cook-Torrance specular BRDF + vec3 spec = (D * G * F) / (4.0 * NdotV * NdotL + 0.0001); + + // Diffuse uses remaining energy not reflected + vec3 kD = (1.0 - F) * (1.0 - metallic); + + // Soft hemisphere ambient + float sky_fac = dot(N, up) * 0.5 + 0.5; + vec3 ambient = mix(ground_color, sky_color, sky_fac) * albedo * 0.25; + + // Key directional light (same 3.0 multiplier as classic) + vec3 diffuse = kD * albedo / PI * light_color * NdotL * 3.0 * diffuse_scale; + + // Fill light: perpendicular to key, slightly elevated + vec3 fill_dir = normalize(cross(up, L) + up * 0.20); + float NdotFill = max(dot(N, fill_dir), 0.0); + vec3 H_fill = normalize(V + fill_dir); + float NdotH_fill = max(dot(N, H_fill), 0.0); + float denom_fill = NdotH_fill * NdotH_fill * (a2 - 1.0) + 1.0; + float D_fill = a2 / (PI * denom_fill * denom_fill); + float G1_fill = NdotFill / (NdotFill * (1.0 - k) + k); + float G_fill = G1_V * G1_fill; + float HdotV_fill = max(dot(H_fill, V), 0.0); + vec3 F_fill = F0 + (F_max - F0) * pow(1.0 - HdotV_fill, 5.0); + vec3 spec_fill = (D_fill * G_fill * F_fill) / (4.0 * NdotV * NdotFill + 0.0001); + vec3 kD_fill = (1.0 - F_fill) * (1.0 - metallic); + diffuse += kD_fill * albedo / PI * light_color * NdotFill * 0.5; + + // Soft back fill (opposite key) + float NdotBack = max(dot(N, -L), 0.0) * 0.08; + diffuse += kD * albedo / PI * light_color * NdotBack; + + // Studio-style rim highlight + float rim = pow(1.0 - max(dot(N, V), 0.0), 3.0) * 0.04; + vec3 rim_color = sky_color * rim; + + // Camera-space catch light for 3D depth + float NdotV_clamp = max(dot(N, V), 0.0); + vec3 catch_light = albedo * light_color * 0.06 * pow(NdotV_clamp, 3.0); + + float shadow = (enable_shadows != 0) ? ShadowCalculation() : 0.0; + vec3 direct = (diffuse + spec * specular_scale + spec_fill * 0.20 * specular_scale) * ground_dampen; + vec3 color = ambient + (1.0 - shadow) * direct + rim_color + catch_light; + + // Apply exposure for brightness control (no ACES — studio uses lower + // light multipliers that stay in a moderate range where filmic rolloff + // would just darken the image and shift hues). + color *= exposure; + color = clamp(color, 0.0, 1.0); + + // gamma correction (sRGB) + color = pow(color, vec3(1.0 / 2.2)); + FragColor = vec4(color, 1.0); +} +""" + + +class ShaderShapeStudio(ShaderShape): + """Studio-style shape shader: 3-point lighting, cast shadows, no fog or env map. + + Uniforms absent from the studio fragment shader return location -1; OpenGL + silently ignores glUniform* calls with location -1, so update() is fully inherited. + """ + + def __init__(self, gl): + super().__init__(gl, fragment_shader=shape_fragment_shader_studio) + + +edge_fragment_shader = """ +#version 330 core +out vec4 FragColor; +in vec4 Material; +uniform vec4 edge_color; +void main() +{ + // Skip ground plane (checker-enabled surfaces) + if (Material.z > 0.0) + discard; + FragColor = edge_color; +} +""" + + wireframe_vertex_shader = """ #version 330 core layout (location = 0) in vec3 aPos; @@ -944,3 +1263,44 @@ def update_frame( def set_world(self, world: np.ndarray): """Set the per-shape world matrix uniform.""" self._gl.glUniformMatrix4fv(self.loc_world, 1, self._gl.GL_FALSE, arr_pointer(world)) + + +class ShaderEdge(ShaderGL): + """Flat-color shader used for the edge/wireframe overlay pass. + + Reuses the instanced shape vertex shader so the second geometry pass + is correctly positioned, then ignores all lighting and outputs a single + uniform ``edge_color``. + """ + + def __init__(self, gl): + super().__init__() + from pyglet.graphics.shader import Shader, ShaderProgram + + self._gl = gl + self.shader_program = ShaderProgram( + Shader(shape_vertex_shader, "vertex"), Shader(edge_fragment_shader, "fragment") + ) + + with self: + self.loc_view = self._get_uniform_location("view") + self.loc_projection = self._get_uniform_location("projection") + self.loc_edge_color = self._get_uniform_location("edge_color") + # light_space_matrix is referenced in the vertex shader; set to identity + # so gl_Position is computed correctly even though it is not used here. + self.loc_light_space_matrix = self._get_uniform_location("light_space_matrix") + + def update( + self, + view_matrix: np.ndarray, + projection_matrix: np.ndarray, + edge_color: tuple[float, float, float, float] = (0.05, 0.05, 0.05, 1.0), + light_space_matrix: np.ndarray | None = None, + ): + """Update shader uniforms for the edge pass.""" + with self: + self._gl.glUniformMatrix4fv(self.loc_view, 1, self._gl.GL_FALSE, arr_pointer(view_matrix)) + self._gl.glUniformMatrix4fv(self.loc_projection, 1, self._gl.GL_FALSE, arr_pointer(projection_matrix)) + self._gl.glUniform4f(self.loc_edge_color, *edge_color) + lsm = light_space_matrix if light_space_matrix is not None else np.eye(4, dtype=np.float32) + self._gl.glUniformMatrix4fv(self.loc_light_space_matrix, 1, self._gl.GL_FALSE, arr_pointer(lsm)) diff --git a/newton/_src/viewer/viewer_gl.py b/newton/_src/viewer/viewer_gl.py index 4bff11831d..d344fdaf86 100644 --- a/newton/_src/viewer/viewer_gl.py +++ b/newton/_src/viewer/viewer_gl.py @@ -21,7 +21,7 @@ from ..utils.render import copy_rgb_frame_uint8 from .camera import Camera from .gl.gui import UI -from .gl.opengl import LinesGL, MeshGL, MeshInstancerGL, RendererGL +from .gl.opengl import STYLE_REGISTRY, LinesGL, MeshGL, MeshInstancerGL, RendererGL from .picking import Picking from .viewer import ViewerBase from .wind import Wind @@ -194,6 +194,7 @@ def __init__( vsync: bool = False, headless: bool = False, plot_history_size: int = 250, + shading_style: str = "classic", ): """ Initialize the OpenGL viewer and UI. @@ -205,6 +206,9 @@ def __init__( headless: Run in headless mode (no window). plot_history_size: Maximum number of samples kept per :meth:`log_scalar` signal for the live time-series plots. + shading_style: Visual rendering style. ``"classic"`` uses Newton's default + checker-floor look. ``"studio"`` uses a clean studio look with soft + hemisphere lighting, directional shadows, and a neutral ground plane. """ if not isinstance(plot_history_size, int) or isinstance(plot_history_size, bool): raise TypeError("plot_history_size must be an integer") @@ -224,7 +228,13 @@ def __init__( super().__init__() - self.renderer = RendererGL(vsync=vsync, screen_width=width, screen_height=height, headless=headless) + self.renderer = RendererGL( + vsync=vsync, + screen_width=width, + screen_height=height, + headless=headless, + shading_style=shading_style, + ) self.renderer.set_title("Newton Viewer") fb_w, fb_h = self.renderer.window.get_framebuffer_size() @@ -2210,6 +2220,16 @@ def _render_left_panel(self): show_collision = self.show_collision changed, self.show_collision = imgui.checkbox("Show Collision", show_collision) + # Edge overlay toggle + changed, self.renderer.draw_edges = imgui.checkbox("Show Edges", self.renderer.draw_edges) + + # Shading style dropdown + shading_styles = list(STYLE_REGISTRY.keys()) + current_style_idx = shading_styles.index(self.renderer.shading_style) + changed, current_style_idx = imgui.combo("Shading", current_style_idx, shading_styles) + if changed: + self.renderer.shading_style = shading_styles[current_style_idx] + # Gap + margin wireframe mode _sdf_margin_labels = ["Off", "Margin", "Margin + Gap"] _, new_sdf_idx = imgui.combo("Gap + Margin", int(self.sdf_margin_mode), _sdf_margin_labels)