Scripting Lua

Streaming de instâncias: a peça não foi destruída, ela foi para nil

O streaming vem ligado por padrão em place novo e não dá para mexer nele por script. A consequência que quebra código: quando uma instância sai, ela é reparentada para nil — e os sinais de remoção disparam sem que nada tenha sido destruído.

por Gustavo Cantino9 min de leituraAvançado

Produzido com IA a partir das fontes citadas abaixo, e conferido por checagens automáticas contra essas mesmas fontes. Nenhuma pessoa leu este texto antes da publicação. Política Editorial

Placa tipográfica com a categoria Scripting Lua em destaque, o nível avançado abaixo, e um campo de barras verticais verde-limão sobre fundo escuro.

Existe um erro de streaming que não aparece em teste nenhum do Studio e aparece em todo jogador: o código trata uma peça que saiu do alcance como uma peça que foi destruída.

Os dois casos disparam o mesmo sinal. E o primeiro é reversível, enquanto o segundo não é. Este guia percorre o streaming a partir dessa distinção, porque é dela que sai quase todo bug do assunto.

O problema

O streaming de instâncias permite ao motor carregar e descarregar conteúdo 3D da Workspace dinamicamente. Os ganhos que a documentação lista são três, e são reais:

  • Entrada mais rápida: o jogador começa numa parte do mundo enquanto o resto carrega ao fundo.
  • Memória: o jogo roda em aparelho com menos memória, porque o conteúdo entra e sai conforme a necessidade.
  • Desempenho: o servidor gasta menos tempo e banda sincronizando o mundo com os jogadores.

Há ainda o nível de detalhe, que mantém modelos e terreno distantes visíveis mesmo sem estarem carregados — e o SLIM entrega a maior fidelidade disso para modelos e avatares.

Duas frases sobre o controle mudam a discussão inteira. A primeira: o streaming é controlado pela propriedade StreamingEnabled da Workspace, e ela vem ligada por padrão em place novo criado no Studio. A segunda: essa propriedade não pode ser definida por script.

Juntas, significam que a maioria dos jogos criados recentemente tem streaming ligado sem que ninguém tenha decidido isso — e que não existe um jeito de desligar temporariamente no código para contornar um bug. Ou o jogo funciona com streaming, ou a configuração muda no editor.

Passo a passo

1. Entenda o escopo. O streaming vale exclusivamente para instâncias descendentes da Workspace. Instâncias em outros contêineres — ReplicatedStorage, ReplicatedFirst — não são elegíveis.

A documentação faz questão de dizer o corolário, porque ele engana: pôr um modelo atômico na ReplicatedStorage não garante replicação atômica. As propriedades de streaming do modelo não significam nada fora da Workspace.

2. Saiba o que não vem na entrada. Quando o jogador entra, as instâncias da Workspace são replicadas exceto: as BaseParts; os modelos marcados como Atomic, Persistent ou PersistentPerPlayer; os modelos Nonatomic, que é o padrão, quando o ModelStreamingBehavior está em Improved; e os descendentes de tudo isso.

É uma lista curta que cobre quase todo o cenário de um jogo. Na prática: o mundo não está lá quando o seu script roda.

3. Saiba como a saída funciona. Durante a partida, o cliente pode remover regiões de BaseParts, conforme o StreamOutBehavior. O processo começa pelas regiões mais distantes dos focos de replicação e vai se aproximando conforme necessário.

Há um piso: regiões dentro do StreamingMinRadius nunca saem. É a garantia mínima em volta do jogador, e é onde vale pôr o que não pode faltar.

Duas isenções importantes. Modelo sem nenhuma BasePart descendente — como um usado só de contêiner para scripts — é isento de sair, a não ser que vire descendente de uma BasePart. E instância criada ou clonada por script de cliente é isenta, a não ser que esteja sob uma instância criada pelo servidor.

Essa segunda isenção é uma ferramenta: efeito visual que o cliente cria fica onde está, desde que não seja pendurado numa peça do servidor.

4. Conheça as isenções como ferramenta. As duas isenções da saída não são curiosidade — elas são o mecanismo pelo qual se garante que algo permaneça.

Um modelo puramente organizacional, sem nenhuma BasePart, fica onde está. Isso faz dele o lugar certo para scripts e valores que precisam existir o tempo todo, independentemente de onde o jogador esteja. Pendurar esse mesmo modelo dentro de uma peça do cenário o transforma em candidato a sair junto com ela.

E o que o cliente cria fica, contanto que não seja pendurado numa instância do servidor. Um efeito de partícula criado localmente e colocado na Workspace persiste; o mesmo efeito colocado dentro da peça que o originou some quando a peça sai.

Artefato

O módulo abaixo acompanha uma instância que pode entrar e sair, e existe para separar as duas coisas que o mesmo sinal não separa.

O quandoEntra roda toda vez que a peça está presente — na primeira vez e em cada retorno. O quandoSai roda quando ela some, e pode rodar várias vezes na mesma sessão.

Repare no que o exemplo de uso não faz no quandoSai: não apaga progresso, não conta como item perdido, não se desconecta. A peça pode voltar em segundos.

E repare no que ele faz no quandoEntra: reaplica a cor. Isso não é redundância — é a próxima seção.

Uma nota sobre como testar isto, porque é a parte que não acontece sozinha: no Studio, com o mapa inteiro carregado e um jogador só, o streaming quase nunca exerce o caminho de saída. O teste que vale é andar até o outro extremo do mapa e voltar, observando o que acontece com o objeto que o seu código acompanha. Se o seu mapa não for grande o bastante para isso, o problema não desapareceu — ele só vai aparecer quando o mapa crescer.

Erro comum

O erro central está numa frase que vale ler devagar: ao sair, a instância é reparentada para nil, para que o estado Luau existente se reconecte se ela voltar.

Daí saem duas consequências que quebram código.

A primeira: sinais de remoção como ChildRemoved e DescendantRemoving disparam no pai ou no ancestral — mas a instância não é destruída no mesmo sentido de uma chamada de Destroy().

O código que escuta ChildRemoved para concluir "o item foi coletado", "o inimigo morreu", "a plataforma foi removida" vai concluir isso toda vez que o jogador se afastar o bastante. E vai concluir de novo quando ele voltar e se afastar outra vez. Num servidor cheio, o mesmo evento dispara dezenas de vezes por sessão.

O teste que distingue é o Parent: saída de streaming deixa a instância viva com Parent nulo, enquanto o que foi destruído não volta. Na prática, o caminho robusto é o do artefato — tratar entrada e saída como estados que alternam, em vez de eventos terminais.

A segunda consequência é mais silenciosa: mudança de propriedade que existe só no cliente e não foi replicada pode ser perdida se a instância sair e voltar.

Esse é o bug que ninguém encontra. O cliente pinta uma peça de verde para marcar que já foi usada, ou esconde um objeto que o jogador coletou localmente. O jogador se afasta, volta, e a peça está do jeito original. Não há erro, não há log, e o comportamento depende da distância — que é a variável que ninguém pensa em testar.

A correção é a do artefato: reaplicar o estado local a cada entrada, em vez de aplicar uma vez e confiar.

Há ainda um detalhe de ordem que economiza depuração: como o StreamingEnabled não se define por script, não existe um modo "streaming desligado só neste teste". A configuração é do place, e mudá-la para investigar um bug muda o comportamento de tudo. O caminho mais rápido costuma ser o inverso — assumir que o streaming está ligado e escrever o código de cliente já preparado para a peça não estar lá.

Próximo passo

O assunto seguinte são as assembleias físicas, porque elas seguem regras próprias e são onde o streaming encosta no desempenho.

Assembleias entram como unidades completas, junto com as Constraints e os Attachments associados — o que garante física consistente no cliente. A única exceção é a assembleia ancorada, em que só as BaseParts dentro do raio entram. E elas não saem até que todas as suas BaseParts estejam elegíveis a sair.

Daí vem uma recomendação que é de arquitetura, não de código: evite assembleia móvel com número desnecessariamente grande de instâncias, porque todas entrando em uníssono pode causar picos de rede e CPU.

Isso muda como se constrói veículo, plataforma móvel e máquina grande. Um trem de duzentas peças articuladas é uma assembleia que entra de uma vez, para todo jogador que chegar perto — e o pico acontece no pior momento possível, que é quando alguém está se aproximando em velocidade.

O exercício prático: liste as assembleias móveis do seu jogo e conte as peças de cada uma. As maiores são candidatas a serem simplificadas, divididas ou ancoradas quando paradas.

E, antes de tudo isso, vale a conferência de base: abra o seu place e veja se o StreamingEnabled está ligado. Se estiver — e em place novo ele está —, todo o código de cliente que assume que a Workspace está inteira já é código com bug esperando distância suficiente para aparecer.

Fontes

Como corrigimos erros

Artefato pronto pra usar

Acompanhar uma peça que pode entrar e sair do mundo, sem confundir saída com destruição (LocalScript)
lua
-- LocalScript: acompanhar uma instância que o streaming pode tirar e
-- devolver, SEM tratar a saída como destruição.
--
-- A distinção que este arquivo existe para fazer:
--
--   SAIU DO STREAMING -> Parent virou nil, o objeto CONTINUA existindo,
--                        e ele pode voltar. Não limpe o estado.
--   FOI DESTRUÍDO     -> não volta nunca. Aí sim, limpe.
--
-- Os dois disparam ChildRemoved no pai. Olhar só o sinal não distingue.

local Observador = {}
Observador.__index = Observador

-- `quandoEntra` roda toda vez que a peça está presente e utilizável.
-- `quandoSai` roda quando ela some do mundo do cliente -- e pode rodar
-- várias vezes ao longo da sessão, alternando com `quandoEntra`.
function Observador.novo(pai, nome, quandoEntra, quandoSai)
	local self = setmetatable({
		pai = pai,
		nome = nome,
		quandoEntra = quandoEntra,
		quandoSai = quandoSai,
		atual = nil,
		conexoes = {},
	}, Observador)

	-- ChildAdded pega tanto a criação quanto o RETORNO do streaming
	table.insert(self.conexoes, pai.ChildAdded:Connect(function(filho)
		if filho.Name == nome then
			self:_entrou(filho)
		end
	end))

	table.insert(self.conexoes, pai.ChildRemoved:Connect(function(filho)
		if filho == self.atual then
			self:_saiu()
		end
	end))

	local ja = pai:FindFirstChild(nome)
	if ja then
		self:_entrou(ja)
	end

	return self
end

function Observador:_entrou(instancia)
	if self.atual == instancia then
		return
	end
	self.atual = instancia
	if self.quandoEntra then
		task.spawn(self.quandoEntra, instancia)
	end
end

function Observador:_saiu()
	local anterior = self.atual
	self.atual = nil
	if self.quandoSai then
		task.spawn(self.quandoSai, anterior)
	end
end

function Observador:destruir()
	for _, c in self.conexoes do
		c:Disconnect()
	end
	self.conexoes = {}
	self.atual = nil
end

--[[
USO -- repare no que NÃO está aqui:

  local alavanca = Observador.novo(workspace, "Alavanca",
      function(peca)
          -- REAPLICAR o visual local a cada entrada. Mudança que só
          -- existe no cliente se perde quando a peça sai e volta, então
          -- ela precisa ser refeita aqui, não feita uma vez lá atrás.
          peca.Color = Color3.fromRGB(0, 255, 0)
      end,
      function()
          -- NÃO apague progresso, NÃO conte como "jogador perdeu o item",
          -- NÃO desconecte o observador. A peça pode voltar em segundos.
      end)
]]

return Observador
Neste artigo

Continue por aqui