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
11 changes: 11 additions & 0 deletions Sources/Rendering/Core/Camera/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,17 @@ export interface vtkCamera extends vtkObject {
/**
* Set the model transform matrix for the camera.
* This matrix could be used for model related transformations such as scale, shear, rotations and translations.
* It is applied to world coordinates before the camera transform, so the
* resulting view matrix is `view * modelTransform`. A common use is a global
* vertical exaggeration, e.g. a scale of (1, 1, 10) on world Z.
*
* The matrix is in gl-matrix column-major order, like `userMatrix` on
* vtkProp3D, so a matrix from vtkTransform or vtkMatrixBuilder can be passed
* directly.
*
* Note the camera pose (position, focalPoint, viewUp, clippingRange) is
* interpreted after this transform is applied, so it is expressed in
* transformed space rather than world space.
* @param {mat4} mat The value of the model transform matrix.
*/
setModelTransformMatrix(mat: mat4): void;
Expand Down
28 changes: 18 additions & 10 deletions Sources/Rendering/Core/Camera/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function vtkCamera(publicAPI, model) {
const upbasis = new Float64Array([0.0, 1.0, 0.0]);
const tmpMatrix = mat4.identity(new Float64Array(16));
const tmpMatrix2 = mat4.identity(new Float64Array(16));
const tmpModelTransform = mat4.identity(new Float64Array(16));
const tmpvec1 = new Float64Array(3);
const tmpvec2 = new Float64Array(3);
const tmpvec3 = new Float64Array(3);
Expand Down Expand Up @@ -506,14 +507,24 @@ function vtkCamera(publicAPI, model) {
}
};

// Compose the model transform into a row-major view matrix so that the
// resulting transform is view * modelTransform, i.e. the model transform is
// applied to world coordinates before the camera transform.
// modelTransformMatrix is supplied by the caller in gl-matrix column-major
// order (like userMatrix on vtkProp3D), while `out` is in vtk's row-major
// order, so the model transform has to be transposed before multiplying.
const applyModelTransform = (out) => {
if (model.modelTransformMatrix) {
mat4.transpose(tmpModelTransform, model.modelTransformMatrix);
mat4.multiply(out, tmpModelTransform, out);
}
return out;
};

publicAPI.getViewMatrix = (out = new Float64Array(16)) => {
if (model.viewMatrix) {
if (model.modelTransformMatrix) {
mat4.multiply(out, model.modelTransformMatrix, model.viewMatrix);
} else {
mat4.copy(out, model.viewMatrix);
}
return out;
mat4.copy(out, model.viewMatrix);
return applyModelTransform(out);
}

mat4.lookAt(
Expand All @@ -525,10 +536,7 @@ function vtkCamera(publicAPI, model) {

mat4.transpose(out, out);

if (model.modelTransformMatrix) {
mat4.multiply(out, model.modelTransformMatrix, out);
}
return out;
return applyModelTransform(out);
};

publicAPI.setProjectionMatrix = (mat) => {
Expand Down
87 changes: 54 additions & 33 deletions Sources/Rendering/Core/Camera/test/testModelTransformMatrix.js
Original file line number Diff line number Diff line change
@@ -1,53 +1,80 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { mat4 } from 'gl-matrix';
import { mat4, vec3 } from 'gl-matrix';
import { areEquals } from 'vtk.js/Sources/Common/Core/Math';
import vtkCamera from 'vtk.js/Sources/Rendering/Core/Camera';
import vtkTransform from 'vtk.js/Sources/Common/Transform/Transform';

let camera;

// getViewMatrix returns vtk's row-major order; transpose to get a matrix that
// can be applied to points with gl-matrix.
function asPointTransform(rowMajor) {
return mat4.transpose(mat4.create(), rowMajor);
}

// Where does world point p land in eye coordinates?
function toEye(cam, p) {
return vec3.transformMat4(
vec3.create(),
p,
asPointTransform(cam.getViewMatrix())
);
}

describe('Camera Model Transform Matrix', () => {
beforeEach(() => {
camera = vtkCamera.newInstance();
});

describe('getViewMatrix composition', () => {
it('applies the model transform in world space (modelTransform * viewMatrix), not camera space (viewMatrix * modelTransform)', () => {
camera.setPosition(5, 5, 5);
it('applies the model transform to world coordinates before the camera transform', () => {
camera.setPosition(0, 0, 10);
camera.setFocalPoint(0, 0, 0);
camera.setViewUp(0, 1, 0);

// A translation does not commute with the view rotation/translation, so
// this genuinely distinguishes the two multiplication orders.
// A translation is neither symmetric nor commuting with the view
// transform, so it distinguishes both the multiplication order and the
// row-major/column-major convention. A pure scale would not.
const transform = vtkTransform.newInstance();
transform.translate(1, 0, 0);
const modelTransform = transform.getMatrix();

camera.setModelTransformMatrix(modelTransform);

// The world origin is moved to (1, 0, 0) by the model transform, so it
// must land one unit off the view axis rather than on it.
const eye = toEye(camera, [0, 0, 0]);
expect(eye[0]).toBeCloseTo(1, 10);
expect(eye[1]).toBeCloseTo(0, 10);
expect(eye[2]).toBeCloseTo(-10, 10);

transform.delete();
});

it('matches view * modelTransform for a non-symmetric transform', () => {
camera.setPosition(5, 5, 5);
camera.setFocalPoint(0, 0, 0);
camera.setViewUp(0, 1, 0);

const transform = vtkTransform.newInstance();
transform.translate(1, 2, 3);
transform.rotateWXYZ(30, 0, 1, 0);
const modelTransform = transform.getMatrix();

camera.setModelTransformMatrix(null);
const viewMatrix = camera.getViewMatrix();
const view = asPointTransform(camera.getViewMatrix());

camera.setModelTransformMatrix(modelTransform);
const composed = camera.getViewMatrix();
const composed = asPointTransform(camera.getViewMatrix());

const worldSpace = mat4.multiply(
mat4.create(),
modelTransform,
viewMatrix
);
const cameraSpace = mat4.multiply(
mat4.create(),
viewMatrix,
modelTransform
);

expect(areEquals(composed, worldSpace)).toBe(true);
expect(areEquals(composed, cameraSpace)).toBe(false);
const expected = mat4.multiply(mat4.create(), view, modelTransform);
expect(areEquals(composed, expected)).toBe(true);

transform.delete();
});

it('keeps the world-space transform consistent across camera orientations (vertical exaggeration)', () => {
// 2x vertical exaggeration: a non-uniform world-space Z scale.
it('keeps a vertical exaggeration world-space across camera orientations', () => {
// 2x exaggeration along world Z.
const transform = vtkTransform.newInstance();
transform.scale(1, 1, 2);
const modelTransform = transform.getMatrix();
Expand All @@ -56,24 +83,18 @@ describe('Camera Model Transform Matrix', () => {
camera.setFocalPoint(0, 0, 0);
camera.setViewUp(0, 1, 0);

// The exaggeration must stay world-space (pre-multiplied), so model
// transforms must be applied before the view matrix for every orientation.
[() => {}, () => camera.azimuth(37), () => camera.elevation(50)].forEach(
(rotate) => {
rotate();

camera.setModelTransformMatrix(null);
const viewMatrix = camera.getViewMatrix();
const view = asPointTransform(camera.getViewMatrix());

camera.setModelTransformMatrix(modelTransform);
const composed = camera.getViewMatrix();

const worldSpace = mat4.multiply(
mat4.create(),
modelTransform,
viewMatrix
);
expect(areEquals(composed, worldSpace)).toBe(true);
const composed = asPointTransform(camera.getViewMatrix());

const expected = mat4.multiply(mat4.create(), view, modelTransform);
expect(areEquals(composed, expected)).toBe(true);
}
);

Expand Down
44 changes: 36 additions & 8 deletions Sources/Rendering/Core/Renderer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ function vtkRenderer(publicAPI, model) {
renderer: publicAPI,
};

// Counterpart of vtkRenderer::ExpandBounds: transform the 8 corners of bounds
// by matrix and take the axis-aligned bounds of the result.
// matrix is in gl-matrix column-major order, matching the camera's
// modelTransformMatrix and what vtkBoundingBox.transformBounds expects.
// Unlike C++, where ModelTransformMatrix is always allocated, vtk-js leaves it
// null by default, so a null matrix is a no-op rather than an error.
function expandBounds(bounds, matrix) {
if (!matrix) {
return bounds;
}
return vtkBoundingBox.transformBounds(bounds, matrix, []);
}

publicAPI.updateCamera = () => {
if (!model.activeCamera) {
vtkDebugMacro('No cameras are on, creating one.');
Expand Down Expand Up @@ -392,13 +405,22 @@ function vtkRenderer(publicAPI, model) {
// the view angle to become very small and cause bad depth sorting.
model.activeCamera.setViewAngle(30.0);

center[0] = (boundsToUse[0] + boundsToUse[1]) / 2.0;
center[1] = (boundsToUse[2] + boundsToUse[3]) / 2.0;
center[2] = (boundsToUse[4] + boundsToUse[5]) / 2.0;
// The camera pose is consumed after the model transform (the view matrix is
// lookAt(position, focalPoint, viewUp) * modelTransformMatrix), so it lives
// in transformed space while prop bounds are in world space. Push the
// bounds through the transform before deriving the pose from them.
const expandedBounds = expandBounds(
boundsToUse,
model.activeCamera.getModelTransformMatrix()
);

let w1 = boundsToUse[1] - boundsToUse[0];
let w2 = boundsToUse[3] - boundsToUse[2];
let w3 = boundsToUse[5] - boundsToUse[4];
center[0] = (expandedBounds[0] + expandedBounds[1]) / 2.0;
center[1] = (expandedBounds[2] + expandedBounds[3]) / 2.0;
center[2] = (expandedBounds[4] + expandedBounds[5]) / 2.0;

let w1 = expandedBounds[1] - expandedBounds[0];
let w2 = expandedBounds[3] - expandedBounds[2];
let w3 = expandedBounds[5] - expandedBounds[4];
w1 *= w1;
w2 *= w2;
w3 *= w3;
Expand Down Expand Up @@ -444,6 +466,8 @@ function vtkRenderer(publicAPI, model) {
center[2] + distance * vn[2]
);

// Pass the untransformed bounds: resetCameraClippingRange applies the model
// transform itself, so handing it expandedBounds would apply it twice.
publicAPI.resetCameraClippingRange(boundsToUse);

// setup default parallel scale
Expand Down Expand Up @@ -479,8 +503,12 @@ function vtkRenderer(publicAPI, model) {
return false;
}

// Get the exact range for the bounds
const range = model.activeCamera.computeClippingRange(boundsToUse);
// computeClippingRange measures along the camera's direction of projection,
// which is expressed in transformed space, so the world-space bounds have to
// be pushed through the model transform first.
const range = model.activeCamera.computeClippingRange(
expandBounds(boundsToUse, model.activeCamera.getModelTransformMatrix())
);

// do not let far - near be less than 0.1 of the window height
// this is for cases such as 2D images which may have zero range
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { mat4 } from 'gl-matrix';
import vtkRenderer from 'vtk.js/Sources/Rendering/Core/Renderer';
import vtkCamera from 'vtk.js/Sources/Rendering/Core/Camera';

// Bounds of a "geology-like" scene: kilometers in X/Y, meters in Z.
const BOUNDS = [-1000, 1000, -1000, 1000, -10, 10];

// 10x vertical exaggeration. The camera stores modelTransformMatrix row-major,
// but a diagonal scale is symmetric so the two conventions coincide here.
function zScale(factor) {
const m = new Float64Array(16);
mat4.identity(m);
m[10] = factor;
return m;
}

let renderer;
let camera;

describe('Renderer resetCamera with camera model transform', () => {
beforeEach(() => {
renderer = vtkRenderer.newInstance();
camera = vtkCamera.newInstance();
renderer.setActiveCamera(camera);
camera.setViewUp(0, 1, 0);
});

it('aims the camera at the transformed center, not the world center', () => {
// Off-center in Z so the transform actually moves the center.
const bounds = [-1000, 1000, -1000, 1000, 90, 110];
camera.setModelTransformMatrix(zScale(10));
renderer.resetCamera(bounds);

// World center Z is 100; after a 10x Z scale the scene center sits at 1000.
expect(camera.getFocalPoint()[2]).toBeCloseTo(1000, 6);
});

it('grows the fitted radius with the exaggeration factor', () => {
camera.setModelTransformMatrix(null);
renderer.resetCamera(BOUNDS);
const unscaled = camera.getParallelScale();

camera.setModelTransformMatrix(zScale(10));
renderer.resetCamera(BOUNDS);
const scaled = camera.getParallelScale();

// Z extent goes from 20 to 200, so the bounding sphere must grow.
expect(scaled).toBeGreaterThan(unscaled);
});

it('resetCameraClippingRange covers the transformed scene along the view axis', () => {
// Look straight down world -Z, so the exaggerated axis is the depth axis and
// the clipping range must stretch to match. This is the case that has no
// application-level call site to fix: interaction paths call
// resetCameraClippingRange() with no arguments.
camera.setPosition(0, 0, 5000);
camera.setFocalPoint(0, 0, 0);
// The factor has to be large enough that the error exceeds the minGap
// padding resetCameraClippingRange already applies, or the bug hides.
camera.setModelTransformMatrix(zScale(100));

renderer.resetCameraClippingRange(BOUNDS);
const [near, far] = camera.getClippingRange();

// Transformed Z extent is [-1000, 1000], so relative to the eye at z=5000
// the scene spans depths 4000..6000.
expect(near).toBeLessThanOrEqual(4000);
expect(far).toBeGreaterThanOrEqual(6000);
});

it('is unchanged when no model transform is set', () => {
camera.setModelTransformMatrix(null);
renderer.resetCamera(BOUNDS);
const focalPoint = [...camera.getFocalPoint()];
const position = [...camera.getPosition()];
const range = [...camera.getClippingRange()];

camera.setModelTransformMatrix(zScale(1));
renderer.resetCamera(BOUNDS);

expect(camera.getFocalPoint()).toEqual(focalPoint);
expect(camera.getPosition()).toEqual(position);
expect(camera.getClippingRange()).toEqual(range);
});
});
Loading