Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

* Added numpy fast path for triangulated mesh data (`_read_frontfaces_data_numpy`, `_read_backfaces_data_numpy`).
* Added dirty flag tracking for settings updates in `BufferManager`.

### Changed

* Made `linewidth` working again through `GeometryShader`.
* Cached uniform locations in `Shader` to avoid per-frame lookups.
* Cached instance color FBO in `Renderer` to avoid per-selection allocation.
* `BufferManager._add_buffer_data` now accepts numpy arrays directly.
* Scene object settings converted to properties with dirty tracking for efficient GPU updates.
* Fixed assertion error in `Camera.projection` when scale is 0.

### Removed

Expand Down
5 changes: 3 additions & 2 deletions src/compas_viewer/renderer/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,15 +395,16 @@ def projection(self, width: int, height: int) -> list[list[float]]:

"""
aspect = width / height
scale = max(self.scale, 1e-6) # Prevent near == far when scale is 0

if self.renderer.view == "perspective":
P = self.perspective(self.fov, aspect, self.near * self.scale, self.far * self.scale)
P = self.perspective(self.fov, aspect, self.near * scale, self.far * scale)
else:
left = -self.distance
right = self.distance
bottom = -self.distance / aspect
top = self.distance / aspect
P = self.ortho(left, right, bottom, top, self.near * self.scale, self.far * self.scale)
P = self.ortho(left, right, bottom, top, self.near * scale, self.far * scale)

return asfortranarray(P, dtype=float32)

Expand Down
91 changes: 56 additions & 35 deletions src/compas_viewer/renderer/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ def __init__(self, viewer: "Viewer"):

self.buffer_manager = BufferManager()

# Cached FBO for instance color picking
self._instance_fbo = None
self._instance_texture = None
self._instance_depth = None
self._instance_fbo_size = (0, 0)

@property
def rendermode(self):
"""
Expand Down Expand Up @@ -588,8 +594,53 @@ def paint(self, is_instance: bool = False):
# Unbind once we're done
GL.glBindVertexArray(0)

def _ensure_instance_fbo(self, width: int, height: int):
"""Create or resize instance picking FBO as needed."""
if self._instance_fbo_size == (width, height) and self._instance_fbo is not None:
return # Already have correct size FBO

# Clean up old resources if they exist
if self._instance_fbo is not None:
GL.glDeleteFramebuffers(1, [self._instance_fbo])
GL.glDeleteTextures(1, [self._instance_texture])
GL.glDeleteRenderbuffers(1, [self._instance_depth])

# Create new FBO
self._instance_fbo = GL.glGenFramebuffers(1)
GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, self._instance_fbo)

# Create texture
self._instance_texture = GL.glGenTextures(1)
GL.glBindTexture(GL.GL_TEXTURE_2D, self._instance_texture)
GL.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA8, width, height, 0, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, None)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
GL.glFramebufferTexture2D(GL.GL_FRAMEBUFFER, GL.GL_COLOR_ATTACHMENT0, GL.GL_TEXTURE_2D, self._instance_texture, 0)

# Create depth buffer
self._instance_depth = GL.glGenRenderbuffers(1)
GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, self._instance_depth)
GL.glRenderbufferStorage(GL.GL_RENDERBUFFER, GL.GL_DEPTH_COMPONENT24, width, height)
GL.glFramebufferRenderbuffer(GL.GL_FRAMEBUFFER, GL.GL_DEPTH_ATTACHMENT, GL.GL_RENDERBUFFER, self._instance_depth)

# Check if FBO is complete
status = GL.glCheckFramebufferStatus(GL.GL_FRAMEBUFFER)
if status != GL.GL_FRAMEBUFFER_COMPLETE:
GL.glDeleteRenderbuffers(1, [self._instance_depth])
GL.glDeleteTextures(1, [self._instance_texture])
GL.glDeleteFramebuffers(1, [self._instance_fbo])
self._instance_fbo = None
self._instance_texture = None
self._instance_depth = None
self._instance_fbo_size = (0, 0)
raise Exception(f"Framebuffer is not complete! Status: {status}")

self._instance_fbo_size = (width, height)
GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, 0)

def read_instance_color(self, box: tuple[int, int, int, int]):
# TODO: Should be able to massively simplify this.
# Get the rectangle area
x1, y1, x2, y2 = box
x, y = min(x1, x2), self.height() - max(y1, y2)
Expand All @@ -600,34 +651,9 @@ def read_instance_color(self, box: tuple[int, int, int, int]):
viewport = GL.glGetIntegerv(GL.GL_VIEWPORT)
previous_fbo = GL.glGetIntegerv(GL.GL_FRAMEBUFFER_BINDING)

# Create an FBO with original window size (not scaled)
fbo = GL.glGenFramebuffers(1)
GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, fbo)

# Create a texture to attach to the FBO
texture = GL.glGenTextures(1)
GL.glBindTexture(GL.GL_TEXTURE_2D, texture)
GL.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA8, self.width(), self.height(), 0, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, None)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
GL.glFramebufferTexture2D(GL.GL_FRAMEBUFFER, GL.GL_COLOR_ATTACHMENT0, GL.GL_TEXTURE_2D, texture, 0)

# Create and attach depth buffer
depth_buffer = GL.glGenRenderbuffers(1)
GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, depth_buffer)
GL.glRenderbufferStorage(GL.GL_RENDERBUFFER, GL.GL_DEPTH_COMPONENT24, self.width(), self.height())
GL.glFramebufferRenderbuffer(GL.GL_FRAMEBUFFER, GL.GL_DEPTH_ATTACHMENT, GL.GL_RENDERBUFFER, depth_buffer)

# Check if FBO is complete
status = GL.glCheckFramebufferStatus(GL.GL_FRAMEBUFFER)
if status != GL.GL_FRAMEBUFFER_COMPLETE:
GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, previous_fbo)
GL.glDeleteRenderbuffers(1, [depth_buffer])
GL.glDeleteTextures(1, [texture])
GL.glDeleteFramebuffers(1, [fbo])
raise Exception(f"Framebuffer is not complete! Status: {status}")
# Ensure we have a properly sized FBO
self._ensure_instance_fbo(self.width(), self.height())
GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, self._instance_fbo)

# Set up rendering state
GL.glViewport(0, 0, self.width(), self.height())
Expand Down Expand Up @@ -702,13 +728,8 @@ def read_instance_color(self, box: tuple[int, int, int, int]):
if not prev_depth_test:
GL.glDisable(GL.GL_DEPTH_TEST)

# Clean up
# Restore previous FBO and viewport (FBO resources are cached, not deleted)
GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, previous_fbo)
GL.glDeleteRenderbuffers(1, [depth_buffer])
GL.glDeleteTextures(1, [texture])
GL.glDeleteFramebuffers(1, [fbo])

# Restore viewport
GL.glViewport(*viewport)

return box_map.reshape(-1, 3)
19 changes: 13 additions & 6 deletions src/compas_viewer/renderer/shaders/shader.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ class Shader:
def __init__(self, name: str = "mesh"):
self.program = make_shader_program(name)
self.locations = {}
self._uniform_locations = {}

def _get_uniform_location(self, name: str) -> int:
"""Get cached uniform location, looking up only once per uniform name."""
if name not in self._uniform_locations:
self._uniform_locations[name] = GL.glGetUniformLocation(self.program, name)
return self._uniform_locations[name]

def uniform4x4(self, name: str, value: list[list[float]]):
"""Store a uniform 4x4 transformation matrix in the shader program at a named location.
Expand All @@ -24,7 +31,7 @@ def uniform4x4(self, name: str, value: list[list[float]]):
A 4x4 transformation matrix.
"""
_value = array(value)
location = GL.glGetUniformLocation(self.program, name)
location = self._get_uniform_location(name)
GL.glUniformMatrix4fv(location, 1, True, _value)

def uniform1i(self, name: str, value: int):
Expand All @@ -37,7 +44,7 @@ def uniform1i(self, name: str, value: int):
value : int
An integer value.
"""
location = GL.glGetUniformLocation(self.program, name)
location = self._get_uniform_location(name)
GL.glUniform1i(location, value)

def uniform1f(self, name: str, value: float):
Expand All @@ -50,7 +57,7 @@ def uniform1f(self, name: str, value: float):
value : float
A float value.
"""
location = GL.glGetUniformLocation(self.program, name)
location = self._get_uniform_location(name)
GL.glUniform1f(location, value)

def uniform3f(self, name: str, value: Union[tuple[float, float, float], list[float]]):
Expand All @@ -63,7 +70,7 @@ def uniform3f(self, name: str, value: Union[tuple[float, float, float], list[flo
value : Union[tuple[float, float, float], list[float]]
An iterable of 3 floats.
"""
location = GL.glGetUniformLocation(self.program, name)
location = self._get_uniform_location(name)
GL.glUniform3f(location, *value)

def uniform2f(self, name: str, value: Union[tuple[float, float], list[float]]):
Expand All @@ -76,7 +83,7 @@ def uniform2f(self, name: str, value: Union[tuple[float, float], list[float]]):
value : Union[tuple[float, float], list[float]]
An iterable of 2 floats.
"""
location = GL.glGetUniformLocation(self.program, name)
location = self._get_uniform_location(name)
GL.glUniform2f(location, *value)

def uniformText(self, name: str, texture: Any):
Expand Down Expand Up @@ -106,7 +113,7 @@ def uniformBuffer(self, name: str, buffer: Any, unit: int = 0):
unit : int
The texture unit to use (0-15 typically available)
"""
location = GL.glGetUniformLocation(self.program, name)
location = self._get_uniform_location(name)
GL.glUniform1i(location, unit) # Use specified texture unit
GL.glActiveTexture(GL.GL_TEXTURE0 + unit)
GL.glBindTexture(GL.GL_TEXTURE_BUFFER, buffer)
Expand Down
83 changes: 67 additions & 16 deletions src/compas_viewer/scene/buffermanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ def __init__(self):
self.settings: List[float] = []
self.object_settings_cache: Dict[Any, List[float]] = {}

# Dirty tracking for settings updates
self._dirty_settings: set = set()

# Initialize empty buffers for each geometry type
for buffer_type in ["_points_data", "_lines_data", "_frontfaces_data", "_backfaces_data"]:
self.positions[buffer_type] = np.array([], dtype=np.float32)
Expand Down Expand Up @@ -106,31 +109,60 @@ def _add_buffer_data(self, obj: Any, buffer_type: str) -> None:
"""Add buffer data for a specific geometry type."""
positions, colors, elements = getattr(obj, buffer_type)

if len(colors) > len(positions):
# Handle numpy arrays from optimized path
is_numpy_positions = isinstance(positions, np.ndarray)
is_numpy_colors = isinstance(colors, np.ndarray)

# Get number of vertices (handle both list and numpy)
n_positions = len(positions) if not is_numpy_positions else positions.shape[0]
n_colors = len(colors) if not is_numpy_colors else colors.shape[0]

if n_colors > n_positions:
print(
f"WARNING: Buffer type: {buffer_type} colors length: {len(colors)} greater than positions length: {len(positions)} for {obj},"
f"WARNING: Buffer type: {buffer_type} colors length: {n_colors} greater than positions length: {n_positions} for {obj},"
"the remaining colors will be ignored"
)
colors = colors[: len(positions)]
elif len(colors) < len(positions):
print(f"WARNING: Buffer type: {buffer_type} colors length: {len(colors)} less than positions length: {len(positions)} for {obj}, last color will be repeated")
colors = colors + [colors[-1]] * (len(positions) - len(colors))
colors = colors[:n_positions]
elif n_colors < n_positions:
if is_numpy_colors:
# Repeat last color row to match positions
last_color = colors[-1:] if len(colors.shape) > 1 else colors[-1]
padding = np.tile(last_color, (n_positions - n_colors, 1)) if len(colors.shape) > 1 else np.full(n_positions - n_colors, last_color)
colors = np.vstack([colors, padding]) if len(colors.shape) > 1 else np.append(colors, padding)
else:
print(f"WARNING: Buffer type: {buffer_type} colors length: {n_colors} less than positions length: {n_positions} for {obj}, last color will be repeated")
colors = colors + [colors[-1]] * (n_positions - n_colors)

# Convert to numpy arrays (skip if already numpy)
if is_numpy_positions:
pos_array = positions.astype(np.float32).flatten()
else:
pos_array = np.array(positions, dtype=np.float32).flatten()

# Convert to numpy arrays
pos_array = np.array(positions, dtype=np.float32).flatten()
col_array = np.array([c.rgba for c in colors] if len(colors) > 0 and isinstance(colors[0], Color) else colors, dtype=np.float32).flatten()
elem_array = np.array(elements, dtype=np.int32).flatten()
if is_numpy_colors:
col_array = colors.astype(np.float32).flatten()
else:
col_array = np.array([c.rgba for c in colors] if len(colors) > 0 and isinstance(colors[0], Color) else colors, dtype=np.float32).flatten()

if isinstance(elements, np.ndarray):
elem_array = elements.astype(np.int32).flatten()
else:
elem_array = np.array(elements, dtype=np.int32).flatten()

if buffer_type == "_frontfaces_data" or buffer_type == "_backfaces_data":
opaque_elements = []
transparent_elements = []
for e in elem_array:
if e >= len(colors):
if e >= n_colors:
# print("WARNING: Element index out of range", obj) # TODO: Fix BREP from IFC
continue

color = colors[e]
alpha = color.a if isinstance(color, Color) else color[3]
if is_numpy_colors:
# Numpy array: access 4th element (alpha) directly
alpha = color[3] if len(color) > 3 else 1.0
else:
alpha = color.a if isinstance(color, Color) else color[3]
if alpha < 1.0 or obj.opacity < 1.0:
transparent_elements.append(e)
else:
Expand All @@ -142,7 +174,7 @@ def _add_buffer_data(self, obj: Any, buffer_type: str) -> None:

# Create vertex indices
object_index = len(self.transforms)
obj_indices = np.full(len(positions), object_index, dtype=np.float32)
obj_indices = np.full(n_positions, object_index, dtype=np.float32)

# Append to existing buffers
self.positions[buffer_type] = np.append(self.positions[buffer_type], pos_array)
Expand Down Expand Up @@ -269,6 +301,7 @@ def clear(self) -> None:
self.transforms = []
self.settings = []
self.object_settings_cache = {}
self._dirty_settings.clear()

def update_object_transform(self, obj: Any) -> None:
"""Update the transformation matrix for a single object.
Expand Down Expand Up @@ -338,10 +371,28 @@ def update_object_data(self, obj: Any) -> None:
col_byte_offset = start_idx * 4 * 4 # 4 floats per color * 4 bytes per float
update_vertex_buffer(col_array, self.buffer_ids[data_type]["colors"], offset=col_byte_offset)

def mark_settings_dirty(self, obj: Any) -> None:
"""Mark an object's settings as needing GPU update.

Parameters
----------
obj : Any
The object whose settings should be marked dirty.
"""
self._dirty_settings.add(obj)

def mark_all_settings_dirty(self) -> None:
"""Mark all objects' settings as needing GPU update."""
self._dirty_settings.update(self.objects.keys())

def update_settings(self):
"""Update the settings for all objects."""
for obj in self.objects:
self.update_object_settings(obj)
"""Update the settings for dirty objects only."""
if not self._dirty_settings:
return
for obj in self._dirty_settings:
if obj in self.objects:
self.update_object_settings(obj)
self._dirty_settings.clear()

def update_object_settings(self, obj: Any) -> None:
"""Update the settings for a single object."""
Expand Down
Loading