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:
7 files changed
+197
-4
No files matched your search
@@ -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` 撤销;
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user