feat(procgen): add landscape.sample and texture.sample PointGraph ops

Sample continuous heightfield/texture-mask scalars onto $Density or float
attributes (optional landscape project), closing the UE-style Texture and
Landscape Sampler gap beyond generate/filter/project-only spatial nodes.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>
This commit is contained in:
Cursor Agentand西风逍遥游 committed 2026-09-14 09:59:36 +00:00
1 parent be714ea243
commit 62cb1d3455
7 files changed
+197 -4

No files matched your search

+2 -1
View File
@@ -31,7 +31,8 @@ Source、视锥与时间预算、Biome Rules、Shape Grammar,以及到 Scene
PointGraph 定义。
PointGraph 一等节点已覆盖 UE 基础节点库中的 Mesh/Spline Sampler、规则网格与 Poisson
蓝噪声采样(`grid.sample` / `poisson.sample`)、点集布尔
蓝噪声采样(`grid.sample` / `poisson.sample`)、Landscape/Texture Sampler
(`landscape.sample` / `texture.sample`)、点集布尔
(Union/Intersection/Difference)、Bounds Modifier、Normal→Density、Static Mesh 权重
Spawner,以及 Disable/Inspect。Landscape/Spline/Actor 源仍通过外部绑定注入;详见
[PointSet 管线](procgen/pointset-pipeline.md)。
@@ -325,6 +325,7 @@ local trees = graph.execute("prune");
支持的 operation 可用 `getOperationCount/getOperationId` 枚举,包括:
`input`、`spatial.sample/filter/project`、`mesh.sample`、`grid.sample`、`poisson.sample`、
`landscape.sample`、`texture.sample`、
`spline.sample`、
`spline.filter.distance`、`merge`、`points.union/intersect/difference`、`copy.points`、
`transform`、`bounds.modify`、`density.remap`、`density.from.normal`、
@@ -333,7 +334,8 @@ local trees = graph.execute("prune");
`biome.generate`、`grammar.generate`、`branch` 和 `subgraph`。
相对 UE PCG 基础节点库,上述一等图节点覆盖了 Mesh/Spline Sampler、规则网格与 Poisson
蓝噪声采样、点集布尔
蓝噪声采样、Landscape/Texture Sampler(把 heightfield / texture mask 连续值写回
`$Density` 或 float 属性)、点集布尔
(Difference/Union/Intersection)、Bounds Modifier、Normal→Density、Static Mesh
权重 Spawner,以及 Disable/Inspect 调试旁路。Landscape / Spline / Actor 源数据通过
图级命名绑定(`setBindingSpatial` / `setBindingPoints`)与零输入 Get 节点
@@ -351,7 +353,10 @@ Partition 与更丰富的 metadata 写入。
volume/heightfield 会在执行期失败。`grid.sample` / `poisson.sample` 为零输入生成节点,
分别对应脚本 `procgen.sampleGrid` 与 `procgen.poissonDisk`:在 XZ 平面生成规则网格或
Bridson 蓝噪声点集,并通过 `originX/Y/Z` 平移到世界坐标;二者都受 `setMaxNodeOutputPoints`
预算约束。`spline.sample` 以控制点 PointSet 为输入,
预算约束。`landscape.sample` 要求 `surface.heightfield`,可选择先 `project` 再把高度
重映射到 `$Density` 或任意 float 通道(`inputMin==inputMax` 时自动用 heightfield Y 范围)。
`texture.sample` 要求 `volume.texture_mask`,把 mask 标量重映射到同一通道。
`spline.sample` 以控制点 PointSet 为输入,
`spline.filter.distance` 用第二输入作为样条控制点做距离筛选。`points.*` 按稳定
point id(legacy 回退到 position+seed)做集合运算,区别于 `merge` 的顺序拼接。
`spawn.mesh` 按权重写入字符串属性(默认 `mesh`),供 Scene sink / 实例化消费;编辑器可用 `PcgInstancePublisher::planPublish/applyPublish` 发布批次,并用 `apply`/`planRemove` 撤销;
+9
View File
@@ -64,6 +64,15 @@ const std::vector<OperationSpec>& operationSpecs() {
{"density.from.normal", 1, {{"minDegrees", "float", "0"}, {"maxDegrees", "float", "90"},
{"outputMin", "float", "0"}, {"outputMax", "float", "1"},
{"invert", "bool", "false"}}},
{"landscape.sample", 1, {{"binding", "string", ""}, {"project", "bool", "false"},
{"attribute", "string", "$Density"},
{"inputMin", "float", "0"}, {"inputMax", "float", "0"},
{"outputMin", "float", "0"}, {"outputMax", "float", "1"},
{"clamp", "bool", "true"}, {"invert", "bool", "false"}}},
{"texture.sample", 1, {{"binding", "string", ""}, {"attribute", "string", "$Density"},
{"inputMin", "float", "0"}, {"inputMax", "float", "1"},
{"outputMin", "float", "0"}, {"outputMax", "float", "1"},
{"clamp", "bool", "true"}, {"invert", "bool", "false"}}},
{"attribute.math.float", 1, {{"attribute", "string", ""},
{"outputAttribute", "string", ""},
{"operation", "string", "multiply"},
+33 -1
View File
@@ -340,6 +340,31 @@ ResultRef<const PointSet> PointGraph::evaluate(const std::string& id, std::unord
densityFromNormal(*first, floatValue(node, "minDegrees", 0.f), floatValue(node, "maxDegrees", 90.f),
floatValue(node, "outputMin", 0.f), floatValue(node, "outputMax", 1.f),
intValue(node, "invert", 0) != 0);
} else if (node.operation == "landscape.sample" || node.operation == "texture.sample") {
if (!first)
return nodeFailure<std::reference_wrapper<const PointSet>>(
DiagnosticCode::InvalidArgument, id, node.operation + " requires input: " + id);
auto spatialResult = resolveNodeSpatial(node);
if (!spatialResult.ok())
return ResultRef<const PointSet>::failure(nestedFailure(id, spatialResult.status()));
const auto spatial = spatialResult.value();
if (node.operation == "landscape.sample" && spatial->getKind() != "surface.heightfield")
return nodeFailure<std::reference_wrapper<const PointSet>>(
DiagnosticCode::InvalidArgument, id, "landscape.sample requires surface.heightfield: " + id);
if (node.operation == "texture.sample" && spatial->getKind() != "volume.texture_mask")
return nodeFailure<std::reference_wrapper<const PointSet>>(
DiagnosticCode::InvalidArgument, id, "texture.sample requires volume.texture_mask: " + id);
std::string attribute = stringValue(node, "attribute", "$Density");
if (attribute.empty()) attribute = "$Density";
if (!isPointFloatChannel(attribute))
return nodeFailure<std::reference_wrapper<const PointSet>>(
DiagnosticCode::InvalidArgument, id, node.operation + " has unknown float channel at node: " + id);
PointSet source = *first;
if (node.operation == "landscape.sample" && intValue(node, "project", 0) != 0) source = spatial->project(source);
node.cache = sampleSpatialOntoChannel(source, *spatial, attribute, floatValue(node, "inputMin", 0.f),
floatValue(node, "inputMax", node.operation == "texture.sample" ? 1.f : 0.f),
floatValue(node, "outputMin", 0.f), floatValue(node, "outputMax", 1.f),
intValue(node, "clamp", 1) != 0, intValue(node, "invert", 0) != 0);
} else if (node.operation == "attribute.math.float") {
const std::string attribute = stringValue(node, "attribute");
std::string outputAttribute = stringValue(node, "outputAttribute");
@@ -827,7 +852,8 @@ Result<void> PointGraph::validateNode(const std::string& id, std::unordered_map<
} else if (node.operation == "get.spatial" || node.operation == "get.landscape" ||
node.operation == "get.spline" || node.operation == "spatial.sample" ||
node.operation == "mesh.sample" || node.operation == "spatial.filter" ||
node.operation == "spatial.project" || node.operation == "biome.generate") {
node.operation == "spatial.project" || node.operation == "biome.generate" ||
node.operation == "landscape.sample" || node.operation == "texture.sample") {
auto spatial = resolveNodeSpatial(node);
if (!spatial.ok()) return Result<void>::failure(nestedFailure(id, spatial.status()));
if (node.operation == "get.landscape" && spatial.value()->getKind() != "surface.heightfield")
@@ -838,6 +864,12 @@ Result<void> PointGraph::validateNode(const std::string& id, std::unordered_map<
if (node.operation == "mesh.sample" && spatial.value()->getKind() != "surface.mesh")
return nodeFailure<void>(DiagnosticCode::InvalidArgument, id,
"mesh.sample requires surface.mesh spatial data: " + id);
if (node.operation == "landscape.sample" && spatial.value()->getKind() != "surface.heightfield")
return nodeFailure<void>(DiagnosticCode::InvalidArgument, id,
"landscape.sample requires surface.heightfield: " + id);
if (node.operation == "texture.sample" && spatial.value()->getKind() != "volume.texture_mask")
return nodeFailure<void>(DiagnosticCode::InvalidArgument, id,
"texture.sample requires volume.texture_mask: " + id);
} else if (node.operation == "grid.sample") {
if (intValue(node, "width", 8) <= 0 || intValue(node, "depth", 8) <= 0 ||
floatValue(node, "spacing", 1.f) <= 0.f)
+57
View File
@@ -5,6 +5,8 @@
#include <algorithm>
#include <cmath>
#include <limits>
#include <string>
#include <string_view>
namespace eve::procgen {
namespace {
@@ -365,6 +367,61 @@ float SpatialData::getMaxX() const { return bounds_.valid ? bounds_.maxX
float SpatialData::getMaxY() const { return bounds_.valid ? bounds_.maxY : 0.f; }
float SpatialData::getMaxZ() const { return bounds_.valid ? bounds_.maxZ : 0.f; }
float SpatialData::sampleScalar(float x, float y, float z, float outsideValue) const {
switch (kind_) {
case Kind::Heightfield: {
if (!heightmap_ || cellSize_ <= 0.f || x < bounds_.minX || x > bounds_.maxX || z < bounds_.minZ ||
z > bounds_.maxZ)
return outsideValue;
return heightmap_->sampleBilinear((x - originX_) / cellSize_, (z - originZ_) / cellSize_) * heightScale_;
}
case Kind::TextureMask: {
if (!heightmap_ || cellSize_ <= 0.f || x < bounds_.minX || x > bounds_.maxX || y < bounds_.minY ||
y > bounds_.maxY || z < bounds_.minZ || z > bounds_.maxZ)
return outsideValue;
return heightmap_->sampleBilinear((x - originX_) / cellSize_, (z - originZ_) / cellSize_);
}
case Kind::MeshSurface: {
if (!mesh_) return outsideValue;
float surfaceY = 0.f, nx = 0.f, ny = 1.f, nz = 0.f;
if (!closestMeshHeight(*mesh_, x, z, y, surfaceY, nx, ny, nz)) return outsideValue;
return surfaceY;
}
default:
return contains(x, y, z) ? 1.f : outsideValue;
}
}
PointSet sampleSpatialOntoChannel(const PointSet& input, const SpatialData& spatial, std::string_view channel,
float inputMin, float inputMax, float outputMin, float outputMax, bool clampOutput,
bool invert) {
std::string target(channel);
if (target.empty()) target = "$Density";
float rangeMin = inputMin;
float rangeMax = inputMax;
if (rangeMin == rangeMax) {
if (spatial.getKind() == "surface.heightfield") {
rangeMin = spatial.getMinY();
rangeMax = spatial.getMaxY();
} else {
rangeMin = 0.f;
rangeMax = 1.f;
}
if (rangeMin == rangeMax) rangeMax = rangeMin + 1.f;
}
const float range = rangeMax - rangeMin;
PointSet result = input;
for (int index = 0; index < result.getCount(); ++index) {
const float raw = spatial.sampleScalar(result.getX(index), result.getY(index), result.getZ(index), rangeMin);
float t = (raw - rangeMin) / range;
if (clampOutput) t = std::clamp(t, 0.f, 1.f);
if (invert) t = 1.f - t;
const float value = outputMin + t * (outputMax - outputMin);
writePointFloatChannel(result, index, target, value).expect("sampleSpatialOntoChannel");
}
return result;
}
PointSet SpatialData::sample(float spacing, uint32_t seed, float jitter) const {
PointSet output;
if (!bounds_.valid || spacing <= 0.f) return output;
+18
View File
@@ -6,6 +6,7 @@
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
namespace eve::procgen {
@@ -90,6 +91,15 @@ public:
float getMaxY() const;
float getMaxZ() const;
/**
* @brief Sample a continuous scalar at a world position.
*
* Heightfields return surface height (ignoring input Y). Texture masks return the
* underlying mask value when XZ (and Y for extrusion) are in bounds. Other kinds
* return 1 when @ref contains is true and @p outsideValue otherwise.
*/
float sampleScalar(float x, float y, float z, float outsideValue = 0.f) const;
/**
* @brief Deterministically sample a 3D lattice inside the domain.
* @param spacing Distance between candidates on all axes.
@@ -123,4 +133,12 @@ private:
std::shared_ptr<SpatialData> right_;
};
/**
* @brief Remap @ref SpatialData::sampleScalar onto a float channel (`$Density` or metadata).
* When @p inputMin equals @p inputMax, heightfields auto-use Y bounds and texture masks use 0..1.
*/
PointSet sampleSpatialOntoChannel(const PointSet& input, const SpatialData& spatial, std::string_view channel,
float inputMin, float inputMax, float outputMin, float outputMax, bool clampOutput,
bool invert);
} // namespace eve::procgen
+71
View File
@@ -569,6 +569,10 @@ TEST_CASE("procgen.pointGraph.exposesUeStyleBreadthNodes") {
CHECK_EQ(PointGraph::getOperationParamKey("spawn.mesh", 1), std::string("attribute"));
CHECK_EQ(PointGraph::getOperationParamKey("grid.sample", 0), std::string("width"));
CHECK_EQ(PointGraph::getOperationParamKey("poisson.sample", 2), std::string("radius"));
CHECK(PointGraph::getOperationInputCount("landscape.sample") == 1);
CHECK(PointGraph::getOperationInputCount("texture.sample") == 1);
CHECK_EQ(PointGraph::getOperationParamKey("landscape.sample", 1), std::string("project"));
CHECK_EQ(PointGraph::getOperationParamKey("texture.sample", 1), std::string("attribute"));
}
TEST_CASE("procgen.pointGraph.samplesGridAndPoissonDisk") {
@@ -640,6 +644,73 @@ TEST_CASE("procgen.pointGraph.samplesGridAndPoissonDisk") {
CHECK(poissonBudget.getError().find("budget") != std::string::npos);
}
TEST_CASE("procgen.pointGraph.samplesLandscapeAndTextureOntoChannels") {
Heightmap heights(3, 3);
for (int z = 0; z < 3; ++z)
for (int x = 0; x < 3; ++x) heights.setHeight(x, z, float(x));
SpatialData landscape = SpatialData::heightfield(heights, 0.f, 0.f, 1.f, 1.f);
PointSet candidates;
candidates.add(0.f, 10.f, 1.f);
candidates.add(2.f, 10.f, 1.f);
candidates.setDensity(0, 0.f);
candidates.setDensity(1, 0.f);
PointGraph landscapeGraph;
CHECK(landscapeGraph.addNode("in", "input"));
CHECK(landscapeGraph.addNode("sample", "landscape.sample"));
CHECK(landscapeGraph.setNodePoints("in", &candidates));
CHECK(landscapeGraph.connect("in", "sample"));
CHECK(landscapeGraph.setNodeSpatial("sample", &landscape));
CHECK(landscapeGraph.setNodeInt("sample", "project", 1));
CHECK(landscapeGraph.setNodeString("sample", "attribute", "$Density"));
CHECK(landscapeGraph.setNodeFloat("sample", "inputMin", 0.f));
CHECK(landscapeGraph.setNodeFloat("sample", "inputMax", 2.f));
CHECK(landscapeGraph.validate());
std::unique_ptr<PointSet> projected(landscapeGraph.execute("sample"));
REQUIRE(bool(projected));
CHECK_EQ(projected->getCount(), 2);
CHECK_EQ(projected->getY(0), 0.f);
CHECK_EQ(projected->getY(1), 2.f);
CHECK_EQ(projected->getDensity(0), 0.f);
CHECK_EQ(projected->getDensity(1), 1.f);
Heightmap mask(2, 2);
mask.setHeight(0, 0, 0.f);
mask.setHeight(1, 0, 1.f);
mask.setHeight(0, 1, 0.25f);
mask.setHeight(1, 1, 0.75f);
SpatialData texture = SpatialData::textureMask(mask, 0.f, 0.f, 1.f, 0.f, 1.f, -1.f, 1.f);
PointSet maskPoints;
maskPoints.add(0.f, 0.f, 0.f);
maskPoints.add(1.f, 0.f, 0.f);
PointGraph textureGraph;
CHECK(textureGraph.addNode("in", "input"));
CHECK(textureGraph.addNode("sample", "texture.sample"));
CHECK(textureGraph.setNodePoints("in", &maskPoints));
CHECK(textureGraph.connect("in", "sample"));
CHECK(textureGraph.setBindingSpatial("mask", &texture).ok());
CHECK(textureGraph.setNodeString("sample", "binding", "mask"));
CHECK(textureGraph.setNodeString("sample", "attribute", "coverage"));
CHECK(textureGraph.validate());
std::unique_ptr<PointSet> covered(textureGraph.execute("sample"));
REQUIRE(bool(covered));
CHECK_EQ(covered->getFloatAttribute(0, "coverage", -1.f), 0.f);
CHECK_EQ(covered->getFloatAttribute(1, "coverage", -1.f), 1.f);
PointGraph wrongKind;
CHECK(wrongKind.addNode("in", "input"));
CHECK(wrongKind.addNode("sample", "texture.sample"));
CHECK(wrongKind.setNodePoints("in", &maskPoints));
CHECK(wrongKind.connect("in", "sample"));
CHECK(wrongKind.setNodeSpatial("sample", &landscape));
CHECK(wrongKind.validate());
CHECK(!wrongKind.execute("sample"));
CHECK(wrongKind.getError().find("texture_mask") != std::string::npos);
}
TEST_CASE("procgen.pointGraph.samplesSplinesFiltersDistanceAndBooleans") {
PointSet controls;
controls.add(0.f, 0.f, 0.f);