Performance Issues When Cloning Large Number of Scenes in a 200×200 Terrain Editor

I have created a Terrain Editor. In this editor, I built a large 200×200 grid.
The data is read from a file and stored in an array, then added to the Viewport using two nested for loops. This approach is very slow. I previously asked about lazy loading and tried several algorithms, but I still experience performance drops when zooming or moving the scene; therefore, for now I am implementing it without lazy loading.By removing TCastleBehavior and creating a subclass of TCastleScene with the required custom fields, I was able to improve performance by about 11 seconds. However, the following call is still very slow and takes more than 253 seconds:
BaseScene[TileType].Clone(FreeAtStop)For this reason, I started thinking that instead of saving and loading data from a file, it might be better to export the entire terrain I created directly to .gltf format, then remove invisible meshes in Blender and load that .gltf file in Castle Game Engine.
However, I could not find an option in CGE to save the scene as .gltf.

1 Like

What you are doing right now is quite similar to what I am doing :smiley: see my current WIP game Watch bb001_20250914 | Streamable and Watch 2026-01-20 23-08-55 | Streamable

Basically CGE does not perform static batching nor hardware instancing, so the more tiles you add to the viewport, the more draw calls the engine needs to perform, which is what kills the performance in your project (200x200 means 40,000 draw calls).

To solve this issue, you need to reduce draw calls. Since your game is top-down, you can group several tiles of the same mesh & material in the same area (5x5, for example) and merge them into a single TCastleScene instead. My game uses this exact technique to keep draw calls low.

Merging tiles is not a beginner task. To do this you need to know how to manually construct a scene. You can study my code which I use in my game to merge tiles (merge Subject tile into Master tile:

class function TBehaviorScript.SEMergeInstance(const VM: TSEVM; const Args: PSEValue; const ArgCount: Cardinal): TSEValue;

  function MinProp(const V: TVector3): Single; inline;
  begin
    Result := V.X;
    if Result > V.Y then
      Result := V.Y;
    if Result > V.Z then
      Result := V.Z;
  end;

  function MaxProp(const V: TVector3): Single; inline;
  begin
    Result := V.X;
    if Result < V.Y then
      Result := V.Y;
    if Result < V.Z then
      Result := V.Z;
  end;

var
  Master,
  Subject: TGameScene;
  DstShapeNode, SrcShapeNode: TShapeNode;
  DstIndexedTriangleSetNode, SrcIndexedTriangleSetNode: TIndexedTriangleSetNode;
  DstCoordNode, SrcCoordNode: TCoordinateNode;
  DstTexCoordNode, SrcTexCoordNode: TTextureCoordinateNode;
  DstNormalNode, SrcNormalNode: TNormalNode;
  DstColorNode, SrcColorNode: TColorRGBANode;
  DstCoordList, SrcCoordList: TVector3List;
  DstTexCoordList, SrcTexCoordList: TVector2List;
  DstNormalList, SrcNormalList: TVector3List;
  DstColorList, SrcColorList: TVector4List;
  DstIndexList, SrcIndexList: TInt32List;
  I, J, Len, Len2: Integer;
  T, V: TVector3;
  M: TMatrix4;
  R: TMatrix3;
  SX, SY, SZ, MinSize, MaxSize: Single;
  DstShapeList, SrcShapeList: TShapeList;
  NeedFullTransform: Boolean;
  BBox: TBox3D;
begin
  SEValidateType(@Args[0], sevkPascalObject, 1, {$I %CURRENTROUTINE%}); // master
  SEValidateType(@Args[1], sevkPascalObject, 2, {$I %CURRENTROUTINE%}); // Subject
  Result := SENull;
  Master := TGameScene(Args[0].VarPascalObject^.Value);
  Subject := TGameScene(Args[1].VarPascalObject^.Value);
  //

  M := Subject.Transform;
  SX := M.Rows[0].XYZ.Length;
  SY := M.Rows[1].XYZ.Length;
  SZ := M.Rows[2].XYZ.Length;
  R.Columns[0] := M.Columns[0].XYZ / SX;
  R.Columns[1] := M.Columns[1].XYZ / SY;
  R.Columns[2] := M.Columns[2].XYZ / SZ;
  DstShapeList := Master.Shapes.TraverseList(False);
  SrcShapeList := Subject.Shapes.TraverseList(False);
  NeedFullTransform := Subject.Rotation.W <> 0;
  if not NeedFullTransform then
    T := Subject.Translation;
  for J := 0 to DstShapeList.Count - 1 do
  begin
    DstShapeNode := DstShapeList.Items[J].Node as TShapeNode;
    SrcShapeNode := SrcShapeList.Items[J].Node as TShapeNode;

    DstIndexedTriangleSetNode := TIndexedTriangleSetNode(DstShapeNode.FindNode(TIndexedTriangleSetNode, False));
    SrcIndexedTriangleSetNode := TIndexedTriangleSetNode(SrcShapeNode.FindNode(TIndexedTriangleSetNode, False));
    DstCoordNode := TCoordinateNode(DstShapeNode.FindNode(TCoordinateNode, False));
    SrcCoordNode := TCoordinateNode(SrcShapeNode.FindNode(TCoordinateNode, False));
    DstTexCoordNode := TTextureCoordinateNode(DstShapeNode.TryFindNode(TTextureCoordinateNode, False));
    SrcTexCoordNode := TTextureCoordinateNode(SrcShapeNode.TryFindNode(TTextureCoordinateNode, False));
    DstNormalNode := TNormalNode(DstShapeNode.TryFindNode(TNormalNode, False));
    SrcNormalNode := TNormalNode(SrcShapeNode.TryFindNode(TNormalNode, False));
    DstColorNode := TColorRGBANode(DstShapeNode.TryFindNode(TColorRGBANode, False));
    SrcColorNode := TColorRGBANode(SrcShapeNode.TryFindNode(TColorRGBANode, False));
    // Find list contain data
    DstCoordList := DstCoordNode.FdPoint.Items;
    SrcCoordList := SrcCoordNode.FdPoint.Items;
    DstTexCoordList := nil;
    DstNormalList := nil;
    DstColorList := nil;
    if DstTexCoordNode <> nil then
    begin
      DstTexCoordList := DstTexCoordNode.FdPoint.Items;
      SrcTexCoordList := SrcTexCoordNode.FdPoint.Items;
    end;
    if DstNormalNode <> nil then
    begin
      DstNormalList := DstNormalNode.FdVector.Items;
      SrcNormalList := SrcNormalNode.FdVector.Items;
    end;
    if DstColorNode <> nil then
    begin
      DstColorList := DstColorNode.FdColor.Items;
      SrcColorList := SrcColorNode.FdColor.Items;
    end;
    DstIndexList := DstIndexedTriangleSetNode.FdIndex.Items;
    SrcIndexList := SrcIndexedTriangleSetNode.FdIndex.Items;
    // Transfer index
    Len2 := DstCoordList.Count;
    Len := DstIndexList.Count;
    DstIndexList.Count := DstIndexList.Count + SrcIndexList.Count;
    for I := 0 to SrcIndexList.Count - 1 do
    begin
      DstIndexList.Ptr(I + Len)^ := SrcIndexList.Items[I] + Len2;
    end;
    // Transfer texcoord
    if DstTexCoordNode <> nil then
    begin
      DstTexCoordList.AddRange(SrcTexCoordList);
    end;
    // Transfer color
    if DstColorList <> nil then
    begin
      DstColorList.AddRange(SrcColorList);
    end;
    // Transfer normal
    if DstNormalNode <> nil then
    begin
      if NeedFullTransform then
      begin
        Len := DstNormalList.Count;
        DstNormalList.Count := DstNormalList.Count + SrcNormalList.Count;
        for I := 0 to SrcNormalList.Count - 1 do
          DstNormalList.Ptr(I + Len)^ := R * SrcNormalList.Items[I];
      end else
      begin
        DstNormalList.AddRange(SrcNormalList);
      end;
    end;
    // Transfer coord + recalculate bbox
    BBox := DstShapeNode.BBox;
    if BBox.IsEmpty then
    begin
      if NeedFullTransform then
        V := (M * Vector4(SrcCoordList.Items[0] * Subject.Scale, 1)).XYZ
      else
        V := T + SrcCoordList.Items[0] * Subject.Scale;
      BBox.Data[1] := V;
      BBox.Data[0] := V + Vector3(0.0001, 0.0001, 0.0001);
      MinSize := MinProp(BBox.Data[1]);
      MaxSize := MaxProp(BBox.Data[0]);
    end;
    if NeedFullTransform then
    begin
      Len := DstCoordList.Count;
      DstCoordList.Count := DstCoordList.Count + SrcCoordList.Count;
      for I := 0 to SrcCoordList.Count - 1 do
      begin
        V := (M * Vector4(SrcCoordList.Items[I] * Subject.Scale, 1)).XYZ;
        DstCoordList.Ptr(I + Len)^ := V;
        if (MinSize > V.X) or (MinSize > V.Y) or (MinSize > V.Z) then
        begin
          MinSize := MinProp(V);
          BBox.Data[1] := Vector3(MinSize, MinSize, MinSize);
        end else
        if (MaxSize < V.X) or (MaxSize < V.Y) or (MaxSize < V.Z) then
        begin
          MaxSize := MaxProp(V);
          BBox.Data[0] := Vector3(MaxSize, MaxSize, MaxSize);
        end
      end;
    end else
    begin
      Len := DstCoordList.Count;
      DstCoordList.Count := DstCoordList.Count + SrcCoordList.Count;
      for I := 0 to SrcCoordList.Count - 1 do
      begin
        V := T + SrcCoordList.Items[I] * Subject.Scale;
        DstCoordList.Ptr(I + Len)^ := V;
        if (MinSize > V.X) or (MinSize > V.Y) or (MinSize > V.Z) then
        begin
          MinSize := MinProp(V);
          BBox.Data[1] := Vector3(MinSize, MinSize, MinSize);
        end else
        if (MaxSize < V.X) or (MaxSize < V.Y) or (MaxSize < V.Z) then
        begin
          MaxSize := MaxProp(V);
          BBox.Data[0] := Vector3(MaxSize, MaxSize, MaxSize);
        end
      end;
    end;
    DstShapeNode.BBox := BBox;
  end;
  Subject.Free;
end;

If you look at my 2nd clip, you will see draw calls information is displayed at the bottom center of the window (around 100-ish, despite the map is 95x95 in size)

2 Likes

hank you for your guidance. Yes, it is really very similar, and I was surprised. Even more than that, I was impressed by its performance compared to mine — my FPS is below 1, although my system is quite old as well.

My tiles are not quadrilateral but hexagonal, and the arrangement of even and odd rows is different.

Thank you for sharing your idea and your code; I am reviewing it now.

My system is not exactly new either, it was a 10-year-old mid-range gaming laptop (Asus GL55VX)

My laptop is about 14 years old with a GeForce M-series graphics card :slightly_smiling_face:
I’ve looked through your code, and I think merging the tiles is really challenging — especially since my tiles are hexagonal.
Still, I’m going to work on it and see how far I can get.

The code simply merge 2 TCastleScene of the same tile into 1, so there’s no limitation on how it works (I originally wrote it for a 3D game). Just make sure your tile model does not contain any transformations.

1 Like

You might look at my client/server terrain demo. It has variable resolution terrain tiles, each tile is a scene and the shape is generated and updated very fast so it can deform in real-time. My tiles are 120x120 because that divides well for lower detail tiles. It currently looks a little weird with the trees because I am waiting for the latest shadow map efforts. You build and run the server. Then build and run the client and it will auto connect to the server on the same machine (or you can run it on different machine). GitHub - xistext/terrainserver: Terrain Client Server

1 Like

Thank you, I’ll definitely take a look.

@kagamma It’s much better now. The performance has improved and the FPS is acceptable.

However, when zooming, some tiles are not rendered.

I have removed lazy loading and merged each group of 100 tiles (10×10) into a single TCastleScene.

Looks like a culling issue. If you manually calculate bounding box then check to see if you do it right (or let the engine automatically calculate it for you)

1 Like

@edj
I wasn’t able to run your project.
I get the following error:
D:\Projects\Delphi\Castel\terrainserver\.\code\gameviewmain.pas(12,3) Fatal: Can't find unit idGlobal used by GameViewMain

I do have Indy installed in Lazarus, though.

When merging TCastleScene objects, I wasn’t calculating the CastleBoxes. I assumed they were computed automatically.
Now I calculate the CastleBoxes manually and the issue is fixed.
However, I would like to know how CGE itself calculates them automatically.

You can call ChangedAll to force a scene to recalculate its bounding box.

1 Like

Hi
What has happened that I can’t select the Plane at the bottom of the screen in my view?
The same issue also occurs in the running application.

https://youtu.be/fN05unP_Xdg

The bounding box related to Plane1:

The bounding box of the tiles:

I am not sure. A simple test case would be nice to have. Since it also affect the editor, maybe @michalis can take a look at it.

In a later part of the video, I created a new view. In that view, the Plane can be selected in the designer, but not at runtime.
I will upload a previous version as well, which had the same issue even in the designer.

I have uploaded a sample of it here.
I will also create another sample for the case where it works correctly in the designer but has an issue at runtime.

@michalis
Here I have shown the steps step by step.
This happens when I change the view setting to ‘Align view to camera’.

Is there no solution for this problem?

In my game I use Physics | Manual | Castle Game Engine instead of the built-in mouse picker and cast the ray myself.
If I remember correctly Michalis did mention at some point the built-in mouse picker (which uses the engine’s own collision detection method) is considered legacy and will likely be replaced by BeRo’s physics engine in the future.

Edit: My code for mouse picker. The code will list all the bodies that get hit by the ray, in your case you can stop at the first hit instead:

class function TBehaviorScript.SEMousePick(const VM: TSEVM; const Args: PSEValue; const ArgCount: Cardinal): TSEValue;
var
  RayCastResult: TPhysicsRayCastResult;
  IgnoredBody: TCastleRigidBody = nil;
  LOrigin: TVector3;
  LDirection: TVector3;
  I: Integer = 0;
  Data: TSEValue;
  V: TVector3;
  LWorld: TCastleTransform;
begin
  SEValidateType(@Args[0], sevkMap, 1, {$I %CURRENTROUTINE%});
  GC.AllocMap(@Result);
  LWorld := SearchTransform(Viewport.Items, 'World') as TCastleTransform;
  if LWorld = nil then
    exit;
  Viewport.PositionToRay(Vector2(Args[0].GetValue(0).VarNumber, Args[0].GetValue(1).VarNumber), True, LOrigin, LDirection);
  LOrigin := LOrigin + (LDirection * -10);
  repeat
    RayCastResult := Viewport.Items.PhysicsRayCast(LOrigin, LDirection, 999999, IgnoredBody);
    if RayCastResult.Transform = nil then
      break;
    IgnoredBody := TCastleRigidBody(RayCastResult.Transform.FindBehavior(TCastleRigidBody));
    LOrigin := RayCastResult.Point;
    V := LWorld.InverseTransform.MultPoint(RayCastResult.Point);
    //
    GC.AllocMap(@Data);
    Data.SetValue('name', RayCastResult.Transform.Name);
    Data.SetValue('x', V.X);
    Data.SetValue('y', V.Y);
    Data.SetValue('z', V.Z);
    Result.SetValue(I, Data);
    Inc(I);
  until False;
end;
2 Likes