Generate 3D tiles in CPP

I’ve data, and I Have a http server on cpp, i don’t want to create files, I want my cpp server to send files generated to the web client, if needed, I made this :


// Helper: Write a 32-bit little-endian value into a buffer.
void HttpServer::writeUint32LE(std::vector<char>& buffer, size_t offset, uint32_t value)
{
    std::memcpy(buffer.data() + offset, &value, sizeof(uint32_t));
}

std::string HttpServer::generatePntsTile()
{
    const uint32_t numPoints = 1000;
    // Define extra bytes for RTC_CENTER (3 floats).
    const uint32_t rtcCenterByteLength = 3 * sizeof(float); // 12 bytes

    // Calculate the size for positions: 3 floats per point.
    const uint32_t posByteLength = numPoints * 3 * sizeof(float);
    // The RGB data will follow positions.
    // Its byte offset is rtcCenterByteLength + posByteLength.
    const uint32_t rgbByteOffset = rtcCenterByteLength + posByteLength;

    // Create the feature table JSON including RTC_CENTER, POSITION, and RGB.
    std::stringstream ss;
    ss << "{\"POINTS_LENGTH\":" << numPoints
       << ",\"RTC_CENTER\":[0,0,0],"
       << "\"POSITION\":{\"byteOffset\":" << rtcCenterByteLength << "},"
       << "\"RGB\":{\"byteOffset\":" << rgbByteOffset << "}}";
    std::string ftJson = ss.str();

    // Pad the JSON string to an 8-byte boundary (using spaces).
    size_t pad = (8 - (ftJson.size() % 8)) % 8;
    std::string paddedFtJson = ftJson;
    paddedFtJson.append(pad, ' ');

    uint32_t featureTableJSONByteLength = static_cast<uint32_t>(paddedFtJson.size());
    // Total binary length: RTC_CENTER + positions + RGB data.
    uint32_t featureTableBinaryByteLength = rtcCenterByteLength + posByteLength + numPoints * 3 * sizeof(uint8_t);
    const uint32_t headerByteLength = 28; // pnts header is 28 bytes.

    uint32_t totalByteLength = headerByteLength + featureTableJSONByteLength + featureTableBinaryByteLength;

    // Prepare a buffer to hold the entire file.
    std::vector<char> buffer(totalByteLength, 0);
    size_t offset = 0;

    // Write header:
    // 1. Magic: "pnts" (4 bytes)
    buffer[offset++] = 'p';
    buffer[offset++] = 'n';
    buffer[offset++] = 't';
    buffer[offset++] = 's';

    // 2. Version (uint32_t, value 1)
    writeUint32LE(buffer, offset, 1);
    offset += 4;

    // 3. ByteLength: total length of the tile.
    writeUint32LE(buffer, offset, totalByteLength);
    offset += 4;

    // 4. FeatureTableJSONByteLength.
    writeUint32LE(buffer, offset, featureTableJSONByteLength);
    offset += 4;

    // 5. FeatureTableBinaryByteLength.
    writeUint32LE(buffer, offset, featureTableBinaryByteLength);
    offset += 4;

    // 6. BatchTableJSONByteLength (0)
    writeUint32LE(buffer, offset, 0);
    offset += 4;

    // 7. BatchTableBinaryByteLength (0)
    writeUint32LE(buffer, offset, 0);
    offset += 4;

    // Copy the padded Feature Table JSON.
    std::memcpy(buffer.data() + offset, paddedFtJson.data(), paddedFtJson.size());
    offset += paddedFtJson.size();

    // Write binary feature table:
    // Write RTC_CENTER: 3 floats, here [0,0,0].
    float rtc_center[3] = { 0.0f, 0.0f, 0.0f };
    std::memcpy(buffer.data() + offset, rtc_center, rtcCenterByteLength);
    offset += rtcCenterByteLength;

    // Seed random number generator.
    std::srand(static_cast<unsigned int>(std::time(nullptr)));

    // Generate binary point data.
    // For each point, generate positions: x in [-50,50], y in [-50,50], z in [0,100].
    for (uint32_t i = 0; i < numPoints; i++)
    {
        float x = static_cast<float>((std::rand() / (double)RAND_MAX) * 100.0 - 50.0);
        float y = static_cast<float>((std::rand() / (double)RAND_MAX) * 100.0 - 50.0);
        float z = static_cast<float>((std::rand() / (double)RAND_MAX) * 100.0);
        std::memcpy(buffer.data() + offset, &x, sizeof(float));
        offset += sizeof(float);
        std::memcpy(buffer.data() + offset, &y, sizeof(float));
        offset += sizeof(float);
        std::memcpy(buffer.data() + offset, &z, sizeof(float));
        offset += sizeof(float);
    }

    // Generate binary color data.
    // For each point, generate 3 bytes (R, G, B).
    for (uint32_t i = 0; i < numPoints; i++)
    {
        uint8_t r = static_cast<uint8_t>(std::rand() % 256);
        uint8_t g = static_cast<uint8_t>(std::rand() % 256);
        uint8_t b = static_cast<uint8_t>(std::rand() % 256);
        buffer[offset++] = r;
        buffer[offset++] = g;
        buffer[offset++] = b;
    }

    // Return the binary data as a std::string (which can hold binary content).
    return std::string(buffer.begin(), buffer.end());
}

// Generate a minimal tileset.json string for a single tile.
std::string HttpServer::generateTilesetJson()
{
    // Use a local constant for PI.
    const double PI = 3.14159265358979323846;

    // Define the region in radians.
    // Paris roughly: 2.35° lon, 48.8566° lat. Here we use a small bounding box around Paris.
    double west  = 2.30 * PI / 180.0;
    double south = 48.80 * PI / 180.0;
    double east  = 2.40 * PI / 180.0;
    double north = 48.90 * PI / 180.0;
    double minHeight = 0.0;
    double maxHeight = 200.0;

    std::stringstream ss;
    // Set fixed notation and precision for floating-point numbers.
    ss << std::fixed << std::setprecision(8);

    ss << "{\n"
       << "  \"asset\": { \"version\": \"1.0\" },\n"
       << "  \"geometricError\": 500,\n"
       << "  \"root\": {\n"
       << "    \"boundingVolume\": {\n"
       << "      \"region\": [" << west << ", " 
                             << south << ", " 
                             << east << ", " 
                             << north << ", " 
                             << minHeight << ", " 
                             << maxHeight << "]\n"
       << "    },\n"
       << "    \"geometricError\": 500,\n"  // non-zero to trigger tile loading
       << "    \"refine\": \"ADD\",\n"
       << "    \"content\": { \"uri\": \"content.pnts\" }\n"
       << "  }\n"
       << "}\n";
    return ss.str();
}

I have no error from cesium, but nothing is showing on the map above paris :frowning:

any idea why ?

Ok, I fix it, you can fin the file generated in attached
test.zip (1.3 MB)

But when i’m trying to render it, it show quickly but disappear … and it’s not the size I expected :

code cesium :

const viewer = new Cesium.Viewer("cesiumContainer", {
  infoBox: false,
  orderIndependentTranslucency: false,
  terrain: Cesium.Terrain.fromWorldTerrain(),
});

viewer.extend(Cesium.viewerCesium3DTilesInspectorMixin);
const inspectorViewModel = viewer.cesium3DTilesInspector.viewModel;

viewer.scene.globe.enableLighting = false;
viewer.scene.skyAtmosphere.show = false;
viewer.scene.globe.showGroundAtmosphere = false;
viewer.scene.fog.enabled = false;
viewer.scene.skyBox.show = false;

viewer.scene.debugShowFramesPerSecond = false;


(async function() {
  const resourceToLoad = 'http://localhost:8080/tileset.json';
  const tileset = await Cesium.Cesium3DTileset.fromUrl(resourceToLoad, {
    enableDebugWireframe: true,
  });
  tileset.style = new Cesium.Cesium3DTileStyle({
		pointSize: 10.0, // Set your desired point size
	});
      tileset.tileset = tileset;
  viewer.scene.primitives.add(tileset);
  viewer.zoomTo(
	tileset,
	new Cesium.HeadingPitchRange(
	  0,
	  -2.0,
	  Math.max(100.0 - tileset.boundingSphere.radius, 0.0),
	),
  );
})();

@Shehzan_Mohammed , @Marco13 any idea ? why does the 3D tiles show and disappear ?

Coincidentally, it looks like the reason here is the same as in another recent thread: The PNTS file contains the vertices “centered at the origin”. The tileset JSON defines a bounding region, which is a place at the surface of the earth. So this just does not match.

I have not yet looked at the code that you posted. My first hint for debugging would have been what you apparently tried out later, namely, actually writing the data into files (which are much easier to analyze than a server that generates the data on the fly). But you might want to check the computation of the bounding region. I think that adding a root.transform in the tileset JSON (as described in the linked thread) might solve this issue here as well.

Just from a local experiment: Here is a tileset JSON that uses a bounding box:

{
  "asset": {
    "version": "1.1"
  },
  "geometricError": 4096,
  "root": {
    "boundingVolume": {
      "box": [
        0,
        0,
        3500,
        5955.3779296875,
        0,
        0,
        0,
        3114.458740234375,
        0,
        0,
        0,
        500
      ]
    },
    "geometricError": 512,
    "content": {
      "uri": "content.pnts"
    },
    "refine": "ADD"
  }
}

And that can be displayed with this sandcastle, which manually places the tileset at a certain position on the globe:

const viewer = new Cesium.Viewer("cesiumContainer");

// Create the tileset in the viewer
const tileset = viewer.scene.primitives.add(
  await Cesium.Cesium3DTileset.fromUrl(
    "http://localhost:8080/tileset-new.json", {
    debugShowBoundingVolume: true,
  })
);

// Move the tileset to a certain position on the globe
const transform = Cesium.Transforms.eastNorthUpToFixedFrame(
  Cesium.Cartesian3.fromDegrees(-75.152408, 39.946975, 20)
);
const scale = 1.0;
const modelMatrix = Cesium.Matrix4.multiplyByUniformScale(
  transform,
  scale,
  new Cesium.Matrix4()
);
tileset.modelMatrix = modelMatrix;

// Zoom to the tileset, with a small offset so that
// it is fully visible
const offset = new Cesium.HeadingPitchRange(
  Cesium.Math.toRadians(-22.5),
  Cesium.Math.toRadians(-22.5),
  60.0
);
viewer.zoomTo(tileset, offset);

@Marco13 I fixed what’s wrong, can you tell me why I can’t access the weight property :

Test.zip (15.0 KB)

I got the warning :

js: Primitive is missing attribute weight, disabling custom fragment shader.

  const tileset = await Cesium.Cesium3DTileset.fromUrl(resourceToLoad);
  
	
	const customShader = new Cesium.CustomShader({
	  fragmentShaderText: `
			void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
				float w = fsInput.attributes.weight;

				if (w < 0.25) {
					material.diffuse = vec3(0.0, 0.0, 1.0); // Blue
				} else if (w < 0.5) {
					material.diffuse = vec3(0.0, 1.0, 0.0); // Green
				} else if (w < 0.75) {
					material.diffuse = vec3(1.0, 1.0, 0.0); // Yellow
				} else {
					material.diffuse = vec3(1.0, 0.0, 0.0); // Red
				}
			}
	  `,
	  varyings: [],
	  uniforms: {},
	  attributes: {
		weight: Cesium.AttributeType.SCALAR,
	  },
	});
	tileset.customShader = customShader;

  tileset.style = new Cesium.Cesium3DTileStyle({
		pointSize: 10.0, // Set your desired point size
	});

  viewer.scene.primitives.add(tileset);

the file pnts contains :
{"POINTS_LENGTH":1000,"RTC_CENTER":[1.02102e+06,4348.75,1.15954e+06],"POSITION":{"byteOffset":12},"WEIGHT":{"byteOffset":12012},"attributes":["POSITION","WEIGHT"]}

From quickly skimming over that, it seems to be called WEIGHT and not weight. If that wasn’t it, I’ll have to allocate some time to check the data locally.

@Marco13
I used 3d-tiles-validator
I changed to WEIGHT :

“Invalid feature table property "WEIGHT".”,

i’m going to use batchtable which is better to have custom property in this case.

From another quick look: It looks like the “weight” is defined in the Feature Table. It may not be as clear as it could be, but application-specific (“user-defined”) properties are stored in the Batch Table.

In the attached file, I simply added a batch table with a weight attribute (so it still contains the data in the feature table - that should be cleaned up when creating the actual data). The actual data is just “dummy data” with values in [0,1]:

Test-FIXED.zip (18.0 KB)

With that, it should be possible to access the weight using the shader that you posted earlier.

1 Like

@Marco13 not sur this will work, because they were no batchID, I have modified the file to add a batchID :

Test.zip (542.8 KB)

But it’s not working, It coudldn’t find the attribute weight

interesting this work :

const style = new Cesium.Cesium3DTileStyle({
  color: {
    // Define a condition or gradient based on WEIGHT
    conditions: [
      ['${WEIGHT} > 0.8', 'color("red")'],
      ['${WEIGHT} > 0.5', 'color("orange")'],
      ['true', 'color("yellow")']
    ]
    // Or use a gradient function if available/appropriate
  }
});
tileset.style = style;

@Marco13 I tried to read the documentation :

You said :

When using the Point Cloud (.pnts ) format, per-point properties are transcoded as property attributes. These property IDs follow the same convention.

but I don’t understand why it’s not working :

			void fragmentMain(FragmentInput fsInput, inout czm_material material) {
				float w = float(fsInput.attributes.weight);
				vec3 color = vec3(1., 1., w);
				material.diffuse = color;
			}

In the code :

  const resourceToLoad = 'http://localhost:8080/tileset.json';
  const tileset = await Cesium.Cesium3DTileset.fromUrl(resourceToLoad);
  
	const customShader = new Cesium.CustomShader({
		fragmentShaderText: `
			void fragmentMain(FragmentInput fsInput, inout czm_material material) {
				float w = float(fsInput.attributes.weight);
				vec3 color = vec3(1., 1., w);
				material.diffuse = color;
			}
		`
	});
	tileset.customShader = customShader;
	

  viewer.scene.primitives.add(tileset);
  viewer.zoomTo(
	tileset,
	new Cesium.HeadingPitchRange(
	  0,
	  -50.0,
	  Math.max(100.0 - tileset.boundingSphere.radius, 0.0),
	),
  );

Yeah. There are some aspects that are not immediately obvious, and I have to admit that I had to read a bit of code and specs, do some debugging, and try out a few things. I first thought that this may be related to a limitation from 3D Tiles Next Metadata Compatibility Matrix · Issue #10480 · CesiumGS/cesium · GitHub . But it turns out that I just did not have enough of the internal handling of the PNTS file format on the radar.

The crucial part here is this line in the code. It basically means that when there is a point cloud with a batch table, but without a BATCH_ID, then the batch table will be turned into an “attribute” that can be accessed in a custom shader.

Here is an example tileset with a PNTS file, including the Sandcastle with the corresponding custom shader:

Cesium Forum 39401 PNTS with attribute.zip (57.3 KB)

The feature table is

{
  "POINTS_LENGTH" : 32768,
  "RTC_CENTER" : [
    1254778.3671113618,
    -4733222.76248441,
    4073666.293879251
  ],
  "POSITION" : {
    "byteOffset" : 0
  }
}

The batch table is

{
  "weight" : {
    "byteOffset" : 0,
    "componentType" : "FLOAT",
    "type" : "SCALAR"
  }
}

And the respective binary parts contain the positions, and a simple scalar weight attribute. When loaded with the given sandcastle, it should look like this:

where the color is determined by the custom shader, from the weight attribute (which just increases from 0.0 to 1.0 in x-direction here…)


Some parts of the handling of PNTS with custom shaders may be not obvious. The custom shader functionality has been developed together with a larger restructuring of the rendering infrastructure, which included a shift from the “legacy” files (PNTS, B3DM…) to glTF and the corresponding metadata extensions. This made it necessary to “translate” some of the “old” features into the new structures, to make sure that the previous behavior still kept working.

1 Like

@Marco13 , I’m sure now that my file is correct because I can see this :

But I can’t access the property WEIGHT in fragment shader :cry: ???

(^That last post had been in the review queue - I assume that it overlapped with my last post. If my last post doesn’t help, I’ll have another look at this thread tomorrow…)

1 Like

thx for the help, removing batchID works :slight_smile: