SurfaceAppearance Studio Beta [Released]

Wow that looks amazing! Can you tell me what skybox your using?

1 Like

Man! This looks SOOO Sick!!! Love all the cool things roblox is making!

Hello, I am testing PBR materials in roblox studio, on a cobble street mesh. I applied all the textures correctly but no difference can be seen in studio. I tried both the shadowmap and future lighting.see on the screen shot the cobble mesh with and the one next to it without PBR. there is no difference.

3 Likes

Try using the rbxassetid format rather than asset links.

2 Likes

thanks for the tip. I tried that but it did not change anything. The material is still not visible as such. I also tried to import the textures directly, that does not work either
I got this line now : 18:20:55.644 - Successfully uploaded compressed SurfaceAppearance.
but I can see nothing. At least the normal map should show!
here a comparison of the 2 meshes in a new game. one with the PBR the other with only a diffuse map.
So

me lighting usually helps to see the effect.
On the past pic the mesh with the color map removed, the others should be visible now, but there is nothing

Interesting. I can’t see your lighting properties, but if I had to guess you are using the voxel setting of lighting technology. SurfaceAppearance only works with the “Future” setting.

2 Likes

i honestly dont see a difference ;-;

1 Like

@Sneerburn it is supposed to work in the shadowmap setting also , but I tried both that AND the future setting (which I am anticipating) but the result is always as above

1 Like

This is not correct. Local lights will only affect SurfaceAppearance in “Future” lighting mode. You also need to make sure your studio graphics mode is set to either Vulkan, DX11, or Metal and that your graphics quality (“Quality Level” and “Edit Quality Level”) is set high enough. Both these settings are found in File > Settings > Rendering.

From the look of your screenshots, I can tell your graphics settings are too low, as buildings are not casting shadows.

7 Likes

Do you mind sending a place file of this? I’d like to check the streets and the lighting settings.

4 Likes

I have a very simple question, maybe because everyone here is quite wanting the same… Do you know when you will release that ? I mean, are you near to release it or you still have a very long way to finish that ?

1 Like

I made a small tutorial on this topic.

4 Likes

It looks like the SurfaceAppearance instance and it’s API are mostly figured out, but I have some feedback related to the API’s scalability with respect to detailed worlds and characters.

With the extra SurfaceAppearance instance, developers are effecively missing out on creating about +50% more PBR MeshParts before a lag spike in some cases (or +35% more Parts with PBR SpecialMeshes). Here are the performance results (test details are at the end of my post):
image

The SurfaceAppearance API looks convenient and simple, but there’s so much more to consider when adding features like this that shape the platform’s future. I plan to support my family through this platform for years to come, and it’s extremely important to me that features are designed with long-term goals in mind.

A lightweight API means we can do more, even if by a small margin. Here’s what we need to be able to achieve from a performance perspective:

  • Low end devices need to efficiently load and run medium worlds with a handful of characters.
  • Medium and high-end devices need to efficiently load and run huge PBR worlds with many PBR characters.
  • Developers using Studio need to be able to view, edit, and create massive PBR worlds without lagging or running low on memory.

Here’s what we need to achieve from a features perspective:

  • Color map recoloring. (Especially important for inclusive character skin tones.)
  • Order-able layering of transparent PBR textures.

Here’s how SurfaceAppearance would hold developers back:

  • Creating the extra instance is expensive.
  • Setting each property individually crosses the “reflection layer” 4 times, which is expensive.
  • The SurfaceAppearance instance is an extra child that needs to be processed in the DataModel.
  • Additional child instances need to iterate over every one of their parents to fire DescendantAdded and DescendantRemoving events. This can get much slower with very long ancestry chains.
  • The SurfaceAppearance instance has 4 individual image content string properties (ColorMap, MetalnessMap, NormalMap, and RoughnessMap) that need to be processed. This can add up for detailed worlds and characters.
  • With SurfaceAppearance.ColorMap, MeshPart.TextureID and SpecialMesh.TextureId become redundant.

Here are 3 API alternatives that can achieve PBR without SurfaceAppearance instances:

Add PBR properties to MeshPart/SpecialMesh

MetalnessMap, NormalMap, and RoughnessMap could instead be added directly to MeshPart and SpecialMesh.

  • Faster than creating an entirely new instance.
  • No new instance type.
  • TextureId property wouldn’t be redundant.
  • These properties could bloat instances that don’t need PBR.
  • Because MeshPart and SpecialMesh are so different, it would likely be more difficult for engineering to maintain identical PBR properties across different instance types.

Add a new data type for image content

A single data type would encapsulate ColorMap, MetalnessMap, NormalMap, and RoughnessMap.

The API naming would need to be well though out, but here’s are a few ideas:

-- TextureMap.new(colorMap, metalnessMap, normalMap, roughnessMap)
MeshPart.TextureMap = TextureMap.new("rbxassetid://5552747292", "rbxassetid://5552747292", "rbxassetid://5552747292", "rbxassetid://5552747292")
MeshPart.TextureMap = TextureMap.Layer({textureMap1, textureMap2})
  • Extremely fast assignment (only crosses the reflection layer once.)
  • No new instance type.
  • Could potentially apply complex image/color transformations and compositing using the data type.
  • Content string processing could be done once when you create a value of this type, instead of each time you assign each string content property.
  • TextureId could be redundant, or if it could read/modify this new data type internally.
  • A new value of this data type would need to be allocated in Lua. (Likely insignificant compared to instance creation/assignment.)
  • Typing this out in code would be tedious.

Add string formatting to the TextureId property

I’m including this suggestion because of its similarity to the RichText beta that’s being worked on. I didn’t initially like the idea of using verbose human-readable strings for something like this, but it would make powerful use of an already existing property and still be easy to use.

The format itself would need to be well thought out and have an intuitive interface in the properties pane, but here’s a verbose human-readable example similar to rich text:

MeshPart.TextureID = "<color>rbxassetid://5552747292</><metalness>rbxassetid://5552747292</><normal>rbxassetid://5552747292</><roughness>rbxassetid://5552747292</>"
  • Extremely fast assignment (only crosses the reflection layer once.)
  • No new instance or data type.
  • Really powerful backwards-compatible format.
  • Could potentially apply complex image/color transformations and compositing using the format.
  • TextureId property is repurposed.
  • Identical strings reuse memory internally and are really fast to create compared to instance creation/assignment. Would likely be faster than a new data type due to internalization, even with a verbose format.
  • Could benefit from utilizing the Lua string hash internally for fast access and assignment.
  • Generally bad practice to expose arbitrary string formats like this.

Performance test details

  • The tests reshuffle and the results are calculated over time.
  • It creates nested folders in workspace to simulate a real use-case.
  • More folder nesting results in worse performance, likely because AncestryChanged needs to be fired for each parent folder.
  • Parts are created, added to the folder, then destroyed.
  • The tests are biased towards parts that are created locally instead of streamed from the server.
  • Test results will be affected by plugins that connect to workspace.DescendantAdded or workspace.DescendantRemoving. (No built-in plugin should ever connect to events like that IMO)

Here’s the source:

Performance code

-- You need to add a MeshPart named "_MeshPartExample" to the workspace for the module to clone.
-- The SurfaceAppearance Studio Beta must also be enabled.

local meshId = "rbxassetid://5552755588"
local textureId = "rbxassetid://5552747292"

-- My MeshPart configuration:
--	MeshId = "rbxassetid://5552755588"
--	RenderFidelity = Enum.RenderFidelity.Precise
--	CollisionFidelity = Enum.CollisionFidelity.Box

local partColor = Color3.new(1, 1, 1)
local partSize = Vector3.new(2, 2, 2)
local partMaterial = Enum.Material.Slate

local meshPartExample = workspace:FindFirstChild("_MeshPartExample"):Clone()
meshPartExample.Anchored = true
meshPartExample.CanCollide = false
meshPartExample.Name = ""
meshPartExample.TextureID = ""
meshPartExample.Material = partMaterial
meshPartExample.Size = partSize


-- Here we create a hierarchy of folders to simulate a real use-case.
-- A high parentDepth results in worse performance because 'DescendantAdded' needs to be fired for each ancestor.
local parentDepth = 4
local parentFolderRoot = Instance.new("Folder")
local parentFolder = parentFolderRoot
for i = 1, parentDepth do
	local m = Instance.new("Folder")
	m.Parent = parentFolder
	parentFolder = m
end

-- Adding to workspace gives us more-meaningful results, but plugins may affect performance when run in studio.
parentFolderRoot.Parent = workspace
local parentFolder = parentFolder


local testList = {
	
	{"control", function(loopCount)
		local t0 = os.clock()
		for i = 1, loopCount do
			local v = i % 256
		end
		return os.clock() - t0
	end},
	
	{"new table", function(loopCount)
		local t0 = os.clock()
		for i = 1, loopCount do
			-- This could get compiled-away because it has no side-effects
			local v = {i % 256}
		end
		return os.clock() - t0
	end},
	
	{"new function", function(loopCount)
		local t0 = os.clock()
		for i = 1, loopCount do
			 -- This could get compiled-away because it has no side-effects
			local v = i % 256
			local f = function()
				return v
			end
		end
		return os.clock() - t0
	end},
	
	{"new Part", function(loopCount)
		local t0 = os.clock()
		for i = 1, loopCount do
			local part = Instance.new("Part")
			part.Anchored = true
			part.CanCollide = false
			part.TopSurface = Enum.SurfaceType.Smooth
			part.BottomSurface = Enum.SurfaceType.Smooth
			part.Material = partMaterial
			part.Color = partColor
			part.Size = partSize
			part.CFrame = CFrame.new(i % 256, 0, 0)
			part.Parent = parentFolder
			part:Destroy()
		end
		return os.clock() - t0
	end},
	
	{"new WedgePart", function(loopCount)
		local t0 = os.clock()
		for i = 1, loopCount do
			local part = Instance.new("WedgePart")
			part.Anchored = true
			part.CanCollide = false
			part.BottomSurface = Enum.SurfaceType.Smooth
			part.Material = partMaterial
			part.Color = partColor
			part.Size = partSize
			part.CFrame = CFrame.new(i % 256, 0, 0)
			part.Parent = parentFolder
			part:Destroy()
		end
		return os.clock() - t0
	end},
	
	{"new MeshPart(TextureID)", function(loopCount)
		local t0 = os.clock()
		for i = 1, loopCount do
			local part = meshPartExample:Clone()
			part.TextureID = textureId
			part.Size = partSize
			part.CFrame = CFrame.new(i % 256, 0, 0)
			part.Parent = parentFolder
			part:Destroy()
		end
		return os.clock() - t0
	end},
	
	{"new MeshPart + SurfaceAppearance(ColorMap)", function(loopCount)
		local t0 = os.clock()
		for i = 1, loopCount do
			local part = meshPartExample:Clone()
			part.Size = partSize
			part.CFrame = CFrame.new(i % 256, 0, 0)
			
			local sa = Instance.new("SurfaceAppearance")
			sa.ColorMap = textureId
			sa.Parent = part
			
			part.Parent = parentFolder
			part:Destroy()
		end
		return os.clock() - t0
	end},
	
	{"new MeshPart + SurfaceAppearance(ColorMap, MetalnessMap, NormalMap, RoughnessMap)", function(loopCount)
		local t0 = os.clock()
		for i = 1, loopCount do
			local part = meshPartExample:Clone()
			part.Size = partSize
			part.CFrame = CFrame.new(i % 256, 0, 0)
			
			local sa = Instance.new("SurfaceAppearance")
			sa.ColorMap = textureId
			sa.MetalnessMap = textureId
			sa.NormalMap = textureId
			sa.RoughnessMap = textureId
			sa.Parent = part
			
			part.Parent = parentFolder
			part:Destroy()
		end
		return os.clock() - t0
	end},
	
	{"new Part + SpecialMesh(TextureId)", function(loopCount)
		local t0 = os.clock()
		for i = 1, loopCount do
			local part = Instance.new("Part")
			part.Anchored = true
			part.CanCollide = false
			part.TopSurface = Enum.SurfaceType.Smooth
			part.BottomSurface = Enum.SurfaceType.Smooth
			part.Size = partSize
			part.CFrame = CFrame.new(i % 256, 0, 0)
			
			local mesh = Instance.new("SpecialMesh")
			mesh.MeshType = Enum.MeshType.FileMesh
			mesh.MeshId = meshId
			mesh.TextureId = textureId
			mesh.Scale = partSize
			mesh.Parent = part
			
			part.Parent = parentFolder
			part:Destroy()
		end
		return os.clock() - t0
	end},
	
	{"new Part + SpecialMesh + SurfaceAppearance(ColorMap)", function(loopCount)
		local t0 = os.clock()
		for i = 1, loopCount do
			local part = Instance.new("Part")
			part.Anchored = true
			part.CanCollide = false
			part.TopSurface = Enum.SurfaceType.Smooth
			part.BottomSurface = Enum.SurfaceType.Smooth
			part.Size = partSize
			part.CFrame = CFrame.new(i % 256, 0, 0)
			
			local mesh = Instance.new("SpecialMesh")
			mesh.MeshType = Enum.MeshType.FileMesh
			mesh.MeshId = meshId
			mesh.Scale = partSize
			mesh.Parent = part
			
			local sa = Instance.new("SurfaceAppearance")
			sa.ColorMap = textureId
			sa.Parent = mesh
			
			part.Parent = parentFolder
			part:Destroy()
		end
		return os.clock() - t0
	end},
	
	{"new Part + SpecialMesh + SurfaceAppearance(ColorMap, MetalnessMap, NormalMap, RoughnessMap)", function(loopCount)
		local t0 = os.clock()
		for i = 1, loopCount do
			local part = Instance.new("Part")
			part.Anchored = true
			part.CanCollide = false
			part.TopSurface = Enum.SurfaceType.Smooth
			part.BottomSurface = Enum.SurfaceType.Smooth
			part.Size = partSize
			part.CFrame = CFrame.new(i % 256, 0, 0)
			
			local mesh = Instance.new("SpecialMesh")
			mesh.MeshType = Enum.MeshType.FileMesh
			mesh.MeshId = meshId
			mesh.Scale = partSize
			mesh.Parent = part
			
			local sa = Instance.new("SurfaceAppearance")
			sa.ColorMap = textureId
			sa.MetalnessMap = textureId
			sa.NormalMap = textureId
			sa.RoughnessMap = textureId
			sa.Parent = mesh
			
			part.Parent = parentFolder
			part:Destroy()
		end
		return os.clock() - t0
	end}
	
}

local testListLen = #testList
local performanceDelayList = table.create(testListLen, 0)

local loopCount = 64 -- Number of loops for each test
local iterationCount = 2048 -- Number of times to shuffle and run the tests
local iterationPrintPeriod = 16

local performanceDelayTotal = 0

local rng = Random.new()
for iteration = 1, iterationCount do
	game:GetService("RunService").Heartbeat:Wait() -- Next frame
	
	-- Randomize test order
	local order = table.create(testListLen)
	for i = 1, testListLen do
		order[i] = i
	end
	for i = 1, testListLen do -- Do a simple shuffle
		local j = rng:NextInteger(1, i)
		order[j], order[i] = order[i], order[j]
	end
	
	-- Run tests
	for _,i in ipairs(order) do
		local perf = testList[i][2](loopCount)
		performanceDelayTotal += perf
		performanceDelayList[i] += perf
	end
	
	-- Print results
	if iteration % iterationPrintPeriod == 1 or iteration == iterationCount then 
		local totalLoops = iteration * loopCount
		print(string.format("\nIteration: %i/%i; Total = %i / %.3f s", iteration, iterationCount, totalLoops, performanceDelayTotal))
		for i = 1, testListLen do
			local perf = performanceDelayList[i]
			print(string.format("\n%s\n\t%.2f / ms (%.2f ns)", testList[i][1], totalLoops / perf / 1000, perf / totalLoops * 1e9))
		end
	end
end

-- Clean up
meshPartExample:Destroy()
parentFolderRoot:Destroy()
36 Likes

Very cool! I finally figured out how to use this to its best, using 4k textures and the new future lighting and this is the end result!



23 Likes

Question, is there a date for the Surface Apperance release (going live on client)

3 Likes

Here’s a topic about release dates:

3 Likes

I may have not read this correctly, but are the default materials updated or going to be updated? (Stone, brick, grass, etc)

3 Likes

I would hope not, or if they did, there be an option to use the current ones, as there wouldn’t really be any point in changing them considering people will be able to upload their own with SurfaceAppearance, and doing so would completely change the appearance of many games, for better or worse (largely worse).

3 Likes

Thanks, but what could I plug in to the properties? Could I put a specular map in the roughness area?

2 Likes

Hey,

so one question about normal maps.

Rounding and bevel normals doesnt look nice on roblox

On roblox the corner looks way too much, on substance its smoothed or beveled like it made it.
its there any fix for this? they both use the same normal map.

By the way on roblox it looks a lot more saturated, and i set the environment lightings to 0.0.0 on studio.

12 Likes