Implement grid-based world origin snapping in View (#9917)

* Implement grid-based world origin snapping in View

Implement a grid-based world origin snapping system in View to avoid
per-frame transform updates in the future. This will allow to
improve CPU performance and to enable future caching of acceleration
structures like BVH.

The feature is protected by the 'view.enable_grid_based_world_origin'
feature flag. The grid size can be set
manually via View::setGridSize or calculated automatically as 10%
of the camera's far plane distance.

A 50% hysteresis ratio is applied to prevent rapid origin flipping
near grid edges.

Exposed the new API to Java and JavaScript bindings and added
unit tests in filament_test.cpp.

BUGS=[504726278]

* Refine grid-based world origin snapping implementation

Refine the grid-based world origin snapping in View with several
improvements:

1. Support Orthographic Projections:
   Calculate automatic grid size using projection matrix elements,
   working for both perspective and ortho without assuming positive
   near plane.

2. Stable Automatic Grid Size:
   Only update effective grid size when a position snap occurs.
   This prevents instability when frustum scale changes smoothly.

3. Immediate Manual Override:
   Force an immediate snap when user manually changes grid size.

Added test cases for ortho and auto-grid size in filament_test.cpp.
This commit is contained in:
Mathias Agopian
2026-04-23 14:19:44 -07:00
committed by GitHub
parent 004b410f41
commit f7fd6a9eab
10 changed files with 299 additions and 11 deletions

View File

@@ -157,6 +157,24 @@ Java_com_google_android_filament_View_nSetDynamicResolutionOptions(JNIEnv*, jcla
view->setDynamicResolutionOptions(options);
}
extern "C" JNIEXPORT void JNICALL
Java_com_google_android_filament_View_nSetGridSize(JNIEnv*, jclass, jlong nativeView, jdouble size) {
View* view = (View*) nativeView;
view->setGridSize(size);
}
extern "C" JNIEXPORT jdouble JNICALL
Java_com_google_android_filament_View_nGetGridSize(JNIEnv*, jclass, jlong nativeView) {
View* view = (View*) nativeView;
return view->getGridSize();
}
extern "C" JNIEXPORT jdouble JNICALL
Java_com_google_android_filament_View_nGetEffectiveGridSize(JNIEnv*, jclass, jlong nativeView) {
View* view = (View*) nativeView;
return view->getEffectiveGridSize();
}
extern "C" JNIEXPORT void JNICALL
Java_com_google_android_filament_View_nGetLastDynamicResolutionScale(JNIEnv *env, jclass, jlong nativeView, jfloatArray out_) {
jfloat* out = env->GetFloatArrayElements(out_, nullptr);

View File

@@ -697,6 +697,33 @@ public class View {
options.quality.ordinal());
}
/**
* Sets the grid size for grid-based world origin snapping.
*
* @param size The size of the grid cell in world units. If set to 0 or negative,
* the grid size is automatically calculated based on the camera frustum.
*/
public void setGridSize(double size) {
nSetGridSize(getNativeObject(), size);
}
/**
* Returns the grid size used for grid-based world origin snapping.
* @return The grid size in world units. A value of 0 or negative means automatic calculation is enabled.
*/
public double getGridSize() {
return nGetGridSize(getNativeObject());
}
/**
* Returns the effective grid size used for grid-based world origin snapping.
* If grid size was set to 0 or negative, this returns the automatically calculated size.
* @return The effective grid size in world units.
*/
public double getEffectiveGridSize() {
return nGetEffectiveGridSize(getNativeObject());
}
/**
* Returns the dynamic resolution options associated with this view.
* @return value set by {@link #setDynamicResolutionOptions}.
@@ -1370,6 +1397,9 @@ public class View {
private static native void nSetDithering(long nativeView, int dithering);
private static native int nGetDithering(long nativeView);
private static native void nSetDynamicResolutionOptions(long nativeView, boolean enabled, boolean homogeneousScaling, float minScale, float maxScale, float sharpness, int quality);
private static native void nSetGridSize(long nativeView, double size);
private static native double nGetGridSize(long nativeView);
private static native double nGetEffectiveGridSize(long nativeView);
private static native void nGetLastDynamicResolutionScale(long nativeView, float[] out);
private static native void nSetRenderQuality(long nativeView, int hdrColorBufferQuality);
private static native void nSetDynamicLightingOptions(long nativeView, float zLightNear, float zLightFar);

View File

@@ -590,6 +590,32 @@ public:
*/
void setDynamicLightingOptions(float zLightNear, float zLightFar) noexcept;
/**
* Sets the grid size for grid-based world origin snapping.
*
* The world origin used for rendering will snap to a grid of this size.
* This avoids recomputing all transforms every frame when the camera moves within a grid cell.
*
* Hysteresis is applied automatically to avoid rapid snapping near edges.
*
* @param size The size of the grid cell in world units. If set to 0 or negative,
* the grid size is automatically calculated based on the camera frustum.
*/
void setGridSize(double size) noexcept;
/**
* Returns the grid size used for grid-based world origin snapping.
* @return The grid size in world units. A value of 0 or negative means automatic calculation is enabled.
*/
double getGridSize() const noexcept;
/**
* Returns the effective grid size used for grid-based world origin snapping.
* If grid size was set to 0 or negative, this returns the automatically calculated size.
* @return The effective grid size in world units.
*/
double getEffectiveGridSize() const noexcept;
/*
* Set the shadow mapping technique this View uses.
*

View File

@@ -58,6 +58,18 @@ Viewport const& View::getViewport() const noexcept {
return downcast(this)->getViewport();
}
void View::setGridSize(double size) noexcept {
downcast(this)->setGridSize(size);
}
double View::getGridSize() const noexcept {
return downcast(this)->getGridSize();
}
double View::getEffectiveGridSize() const noexcept {
return downcast(this)->getEffectiveGridSize();
}
void View::setFrustumCullingEnabled(bool const culling) noexcept {
downcast(this)->setFrustumCullingEnabled(culling);
}

View File

@@ -179,7 +179,7 @@ FView::FView(FEngine& engine)
FgviewerManager* fgviewerManager = engine.debug.fgviewer;
if (UTILS_LIKELY(fgviewerManager)) {
mFrameGraphViewerViewHandle =
fgviewerManager->createView(utils::CString(getName()));
fgviewerManager->createView(CString(getName()));
}
#endif
@@ -533,27 +533,132 @@ void FView::prepareLighting(FEngine& engine, CameraInfo const& cameraInfo) noexc
getColorPassDescriptorSet().prepareDirectionalLight(engine, exposure, sceneSpaceDirection, directionalLight);
}
/*
* Calculates an automatic grid size based on the camera frustum dimensions.
* Handles both perspective and orthographic projections.
*
* For perspective projections, it uses the width of the frustum at the far plane.
* This ensures that the grid size scales with the visible volume and accounts for
* field-of-view (zooming in reduces grid size to preserve precision).
* For orthographic projections, it uses the absolute width of the frustum.
*
* camera: The camera to use for the calculation.
* Returns the calculated grid size (10% of the computed width, chosen as a
* reasonable balance between precision and snapping frequency).
*/
double FView::calculateAutomaticGridSize(const FCamera* camera) const noexcept {
auto const& p = camera->getCullingProjectionMatrix();
// Base scale is the width of the frustum at Z=1 for perspective,
// or the absolute width for orthographic.
double baseScale = 2.0 / std::abs(p[0][0]);
// Detect perspective by checking if P[3][2] is non-zero
bool const isPerspective = std::abs(p[3][2]) > 1e-5;
if (isPerspective) {
double const zf = camera->getCullingFar();
// Scale at far plane
return baseScale * zf * 0.1;
} else {
// Ortho: baseScale is the full width of the frustum.
// Use 10% of the width as grid size.
return baseScale * 0.1;
}
}
/*
* Computes a stable grid origin for the camera to improve floating-point precision.
* Snapping only occurs when the camera moves beyond the grid boundary plus hysteresis.
*
* This implementation follows Strategy A: only update the grid size when a position snap occurs.
* This prevents instability when the frustum (and thus auto grid size) changes smoothly.
* Alternative Strategy B (scale hysteresis) could be used if we want to respond to large scale changes without moving.
*
* cameraPosition: The current world position of the camera.
* currentGridSize: The grid size used in the previous frame (stable).
* newGridSize: The new calculated grid size (target).
* hysteresisRatio: The hysteresis margin as a ratio of the grid size [0, 1].
* forceSnap: Force a snap regardless of threshold (used for manual grid size changes).
* Returns the stable grid origin.
*/
double3 FView::computeGridOrigin(double3 cameraPosition, double currentGridSize, double newGridSize, double hysteresisRatio, bool forceSnap) const noexcept {
if (currentGridSize <= 0.0) {
return cameraPosition;
}
const double threshold = currentGridSize * (0.5 + hysteresisRatio);
const double3 currentOrigin = mGridOrigin;
// Check threshold per axis (without loop)
bool const snapX = std::abs(cameraPosition.x - currentOrigin.x) > threshold;
bool const snapY = std::abs(cameraPosition.y - currentOrigin.y) > threshold;
bool const snapZ = std::abs(cameraPosition.z - currentOrigin.z) > threshold;
if (snapX || snapY || snapZ || forceSnap) {
// Snap triggered! Use new grid size to compute new origin.
double3 const newOrigin = {
std::round(cameraPosition.x / newGridSize) * newGridSize,
std::round(cameraPosition.y / newGridSize) * newGridSize,
std::round(cameraPosition.z / newGridSize) * newGridSize
};
mGridOrigin = newOrigin;
mEffectiveGridSize = newGridSize;
}
return mGridOrigin;
}
CameraInfo FView::computeCameraInfo(FEngine const& engine) const noexcept {
FScene const* const scene = getScene();
/*
* We apply a "world origin" to "everything" in order to implement the IBL rotation.
* The "world origin" is also used to keep the origin close to the camera position to
* The "world origin" is also used to keep the origin close to the camera position (or snapped grid) to
* improve fp precision in the shader for large scenes.
*/
double3 translation;
mat3 rotation;
double3 translation = 0.0;
mat3 rotation{ 1.0f };
/*
* Calculate all camera parameters needed to render this View for this frame.
*/
FCamera const* const camera = mViewingCamera ? mViewingCamera : mCullingCamera;
double3 const cameraPosition = camera->getPosition();
// Internal policy controlled by feature flag and debug flags
if (engine.debug.view.camera_at_origin) {
// this moves the camera to the origin, effectively doing all shader computations in
// view-space, which improves floating point precision in the shader by staying around
// zero, where fp precision is highest. This also ensures that when the camera is placed
// very far from the origin, objects are still rendered and lit properly.
translation = -camera->getPosition();
if (engine.features.view.enable_grid_based_world_origin) {
// This moves the camera to a snapped grid origin, improving floating point precision
// while avoiding per-frame transform updates for objects as long as the camera
// stays within the grid cell (plus hysteresis).
// Determine the target grid size (either manual or automatic).
double newGridSize = mGridSize;
if (newGridSize <= 0.0) {
newGridSize = calculateAutomaticGridSize(camera);
}
// For the first frame, initialize the effective grid size.
double currentGridSize = mEffectiveGridSize;
if (currentGridSize <= 0.0) {
// First time initialization
currentGridSize = newGridSize;
mEffectiveGridSize = currentGridSize;
}
// Force snap if user manually changed grid size to a positive value
bool const forceSnap = (mGridSize > 0.0 && mGridSize != currentGridSize);
constexpr double hysteresisRatio = 0.5; // Automatic 50% hysteresis
translation = -computeGridOrigin(cameraPosition, currentGridSize, newGridSize, hysteresisRatio, forceSnap);
} else {
// this moves the camera to the origin, effectively doing all shader computations in
// view-space, which improves floating point precision in the shader by staying around
// zero, where fp precision is highest. This also ensures that when the camera is placed
// very far from the origin, objects are still rendered and lit properly.
translation = -cameraPosition;
}
}
FIndirectLight const* const ibl = scene->getIndirectLight();
@@ -1447,7 +1552,7 @@ void FView::setTemporalAntiAliasingOptions(TemporalAntiAliasingOptions options)
void FView::setMultiSampleAntiAliasingOptions(MultiSampleAntiAliasingOptions options) noexcept {
// MSAA is a post-process effect, and post-processing is disabled at FL0
if (mFeatureLevel >= backend::FeatureLevel::FEATURE_LEVEL_1) {
if (mFeatureLevel >= FeatureLevel::FEATURE_LEVEL_1) {
options.sampleCount = uint8_t(options.sampleCount < 1u ? 1u : options.sampleCount);
mMultiSampleAntiAliasingOptions = options;
assert_invariant(!options.enabled || !mRenderTarget || !mRenderTarget->hasSampleableDepth());

View File

@@ -125,6 +125,10 @@ public:
return mViewport;
}
void setGridSize(double size) noexcept { mGridSize = size; }
double getGridSize() const noexcept { return mGridSize; }
double getEffectiveGridSize() const noexcept { return mEffectiveGridSize; }
bool getClearTargetColor() const noexcept {
// don't clear the color buffer if we have a skybox
return !isSkyboxVisible();
@@ -541,6 +545,9 @@ private:
void prepareVisibleRenderables(utils::JobSystem& js,
Frustum const& frustum, FScene::RenderableSoa& renderableData) const noexcept;
math::double3 computeGridOrigin(math::double3 cameraPosition, double currentGridSize, double newGridSize, double hysteresisRatio, bool forceSnap = false) const noexcept;
double calculateAutomaticGridSize(const FCamera* camera) const noexcept;
void updateUBOs(backend::DriverApi& driver,
FScene::RenderableSoa& renderableData,
utils::Range<uint32_t> visibleRenderables) noexcept;
@@ -583,6 +590,9 @@ private:
uint32_t mFroxelConfigurationAge = 0;
Viewport mViewport;
double mGridSize = 0.0;
mutable double mEffectiveGridSize = 0.0;
mutable math::double3 mGridOrigin{ 0.0 };
bool mCulling = true;
bool mFrontFaceWindingInverted = false;
bool mIsTransparentPickingEnabled = false;

View File

@@ -41,6 +41,7 @@
#include "details/Camera.h"
#include "Froxelizer.h"
#include "details/Engine.h"
#include "details/View.h"
#include "components/RenderableManager.h"
#include "components/TransformManager.h"
#include "UniformBuffer.h"
@@ -803,6 +804,83 @@ TEST(FilamentTest, GoogleLineDirective) {
}
}
TEST(FilamentTest, GridSnapping) {
FEngine* engine = downcast(Engine::create(backend::Backend::NOOP));
FView* view = downcast(engine->createView());
FScene* scene = downcast(engine->createScene());
Entity cameraEntity = engine->getEntityManager().create();
FCamera* camera = downcast(engine->createCamera(cameraEntity));
view->setScene(scene);
view->setCamera(camera);
engine->features.view.enable_grid_based_world_origin = true;
engine->debug.view.camera_at_origin = true;
view->setGridSize(10.0);
// Test case 1: Camera at (0,0,0)
camera->setModelMatrix(mat4::translation(double3{0.0, 0.0, 0.0}));
CameraInfo ci = view->computeCameraInfo(*engine);
EXPECT_NEAR(ci.worldTransform[3].x, 0.0f, 1e-5f);
// Test case 2: Camera at (9.9,0,0) - should not snap yet if hysteresis is 50%
// Grid size is 10, threshold is 10.
camera->setModelMatrix(mat4::translation(double3{9.9, 0.0, 0.0}));
ci = view->computeCameraInfo(*engine);
EXPECT_NEAR(ci.worldTransform[3].x, 0.0f, 1e-5f);
// Test case 3: Camera at (10.1,0,0) - should snap to 10
camera->setModelMatrix(mat4::translation(double3{10.1, 0.0, 0.0}));
ci = view->computeCameraInfo(*engine);
// If it snapped to 10, the translation applied to world should be -10
EXPECT_NEAR(ci.worldTransform[3].x, -10.0f, 1e-5f);
// Test case 4: Move back to (1.0,0,0) - should stay at 10 due to hysteresis
// Diff to origin (10) is 9.0 < 10.
camera->setModelMatrix(mat4::translation(double3{1.0, 0.0, 0.0}));
ci = view->computeCameraInfo(*engine);
EXPECT_NEAR(ci.worldTransform[3].x, -10.0f, 1e-5f);
// Test case 5: Move to (-0.1,0,0) - should snap back to 0
// Diff to origin (10) is 10.1 > 10.
camera->setModelMatrix(mat4::translation(double3{-0.1, 0.0, 0.0}));
ci = view->computeCameraInfo(*engine);
EXPECT_NEAR(ci.worldTransform[3].x, 0.0f, 1e-5f);
// Test case 6: Automatic grid size (Perspective)
view->setGridSize(0.0); // Enable auto
static_cast<Camera*>(camera)->setProjection(90.0, 1.0, 0.1, 100.0, Camera::Fov::VERTICAL); // Set far plane to 100
// FOV 90, aspect 1.0 -> baseScale = 2.0. Width at far plane = 200.
// Auto grid size should be 2.0 * 100 * 0.1 = 20.
camera->setModelMatrix(mat4::translation(double3{0.0, 0.0, 0.0}));
ci = view->computeCameraInfo(*engine);
EXPECT_NEAR(ci.worldTransform[3].x, 0.0f, 1e-5f);
camera->setModelMatrix(mat4::translation(double3{20.1, 0.0, 0.0}));
ci = view->computeCameraInfo(*engine);
EXPECT_NEAR(ci.worldTransform[3].x, -20.0f, 1e-5f); // Should snap to 20
// Test case 7: Ortho automatic grid size
view->setGridSize(0.0); // Enable auto
static_cast<Camera*>(camera)->setProjection(Camera::Projection::ORTHO, -10.0, 10.0, -10.0, 10.0, -100.0, 100.0);
// Width is 20. Auto grid size should be 20 * 0.1 = 2.
// Move to -0.1 to trigger snap from previous origin (20) with threshold 20
camera->setModelMatrix(mat4::translation(double3{-0.1, 0.0, 0.0}));
ci = view->computeCameraInfo(*engine);
EXPECT_NEAR(ci.worldTransform[3].x, 0.0f, 1e-5f); // Should snap to 0
camera->setModelMatrix(mat4::translation(double3{2.1, 0.0, 0.0}));
ci = view->computeCameraInfo(*engine);
EXPECT_NEAR(ci.worldTransform[3].x, -2.0f, 1e-5f); // Should snap to 2
engine->destroy(scene);
engine->destroy(view);
engine->destroyCameraComponent(cameraEntity);
engine->getEntityManager().destroy(cameraEntity);
Engine::destroy((Engine **)&engine);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();

View File

@@ -97,6 +97,9 @@ public:
bool enable_material_instance_uniform_batching = false;
bool enable_fog_as_postprocess = false;
} material;
struct {
bool enable_grid_based_world_origin = false;
} view;
} features;
public:

View File

@@ -57,7 +57,7 @@ std::string_view getFeatureFlagFromEnvironment(char const* const name, std::arra
}
#endif
void overrideFeatureDefaults(utils::Slice<FeatureFlagManager::FeatureFlag> const& features) {
void overrideFeatureDefaults(Slice<FeatureFlagManager::FeatureFlag> const& features) {
std::array<char, 128> storage;
UTILS_NOUNROLL
for (auto const& feature : features) {
@@ -145,6 +145,9 @@ FeatureFlagManager::FeatureFlagManager() : mFeatures{{
{ "material.enable_fog_as_postprocess",
"Fog is applied as a separate pass for opaque objects.",
&features.material.enable_fog_as_postprocess },
{ "view.enable_grid_based_world_origin",
"Enable grid-based world origin snapping to improve precision and avoid per-frame transform updates.",
&features.view.enable_grid_based_world_origin },
}} {
overrideFeatureDefaults({ mFeatures.data(), mFeatures.size() });
}

View File

@@ -727,6 +727,9 @@ class_<View>("View")
.function("getBlendMode", &View::getBlendMode)
.function("setViewport", &View::setViewport)
.function("getViewport", &View::getViewport)
.function("setGridSize", &View::setGridSize)
.function("getGridSize", &View::getGridSize)
.function("getEffectiveGridSize", &View::getEffectiveGridSize)
.function("setVisibleLayers", &View::setVisibleLayers)
.function("setPostProcessingEnabled", &View::setPostProcessingEnabled)
.function("setDithering", &View::setDithering)